3f06919b90ed
restructure browser sync around public/private/off relay modes and delete the LAN transport: pairing codes, subnet probing, the address cache and the local-network module all go, leaving one relay path (v4.2.0)
- time
- 2026-07-26 15:41 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- LAN retirement
- parent
- 74a005e256a7
↓ peach-browser-3f06919b.tar.gzatlas at this save
files changed30 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+0 −142src/background/code-pairing.test.ts+0 −425src/background/code-pairing.ts+0 −202src/background/device-identity.ts+37 −0src/background/messages.ts+26 −43src/background/relay-adoption-source-bg.test.ts+16 −7src/background/relay-adoption-source-bg.ts+8 −8src/background/relay-settings.test.ts+0 −75src/background/relay-settings.ts+0 −46src/background/sync-runner.test.ts+19 −182src/background/sync-runner.ts+18 −121src/background/sync-settings.ts+105 −0src/background/sync-static.test.ts+4 −2src/popup/App.tsx+11 −41src/popup/styles/popup.css+46 −0src/popup/views/AdoptVaultView.test.tsx+1 −1src/popup/views/AdoptVaultView.tsx+41 −114src/popup/views/PairDevice.test.tsx+5 −255src/popup/views/PairDevice.tsx+44 −311src/popup/views/Settings.tsx+92 −59src/shared/local-network.ts+0 −28src/shared/pairing-code.test.ts+0 −100src/shared/pairing-code.ts+0 −168src/shared/pairing-dial.test.ts+0 −193src/shared/pairing-dial.ts+6 −36src/shared/sync-types.ts+5 −4src/shared/types.ts+4 −8manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.13",4+ "version": "4.2.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.13",4+ "version": "4.2.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.1.13",3+ "version": "4.2.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/code-pairing-bg.ts+0 −142
@@ -1,142 +0,0 @@1-// Background wiring for code pairing.2-//3-// Keeps `code-pairing.ts` a PURE, dependency-injected client (so its unit tests4-// run without chrome / vault / WebSocket). This module supplies the production5-// `CodePairingDeps` and the single-flight SAS/confirm round-trip that bridges6-// the blocking `confirm(sas)` callback to the popup via runtime messages:7-//8-// PAIR_WITH_CODE → startCodePairing() (popup → bg, messages.ts)9-// CODE_PAIR_SAS ← push when onSasReady fires (bg → popup)10-// CODE_PAIR_CONFIRM → resolveCodePairingConfirm() (popup → bg, messages.ts)11-// CODE_PAIR_RESULT ← push on complete/error (bg → popup)12-13-import {14- pairWithCode,15- type CodePairingDeps,16- type CodePairingCallbacks,17-} from "./code-pairing";18-import { openPairingSocket } from "../shared/pairing-dial";19-import {20- clearDeviceRevocation,21- upsertPairedDevice,22- setLastKnownIp,23-} from "./paired-devices";24-import { buildLocalIdentity } from "./device-identity";25-import { computeVaultId, deriveSyncIdentityKeypair } from "../shared/sync-identity";26-import { isUnlocked, getRootKeyHex } from "./vault";27-import type { LocalIdentity } from "../shared/pairing-handshake";28-import { getRelaySettings } from "./relay-settings";29-30-function hexToBytesLocal(hex: string): Uint8Array {31- const out = new Uint8Array(hex.length / 2);32- for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);33- return out;34-}35-36-/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */37-function push(type: string, payload: unknown): void {38- try {39- const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;40- if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {41- (maybe as Promise<unknown>).catch(() => undefined);42- }43- } catch {44- // Popup closed / no receiver — fine.45- }46-}47-48-/** Production dependency set — real WebSocket, identity, and storage. */49-export function productionCodePairingDeps(): CodePairingDeps {50- return {51- openSocket: (ip, port) => openPairingSocket(ip, port),52- async getRelayUrl() {53- return (await getRelaySettings()).relayUrl;54- },55- async getLocalIdentity(): Promise<{56- identity: LocalIdentity;57- privateKey: Uint8Array;58- publicKey: Uint8Array;59- }> {60- if (!isUnlocked()) throw new Error("vault_locked");61- const masterKeyHex = getRootKeyHex();62- if (!masterKeyHex) throw new Error("vault_locked");63- // SEV-2-B: derive from the stable ROOT key so pairing survives a64- // master-password change. Zero the copy when done.65- const masterKey = hexToBytesLocal(masterKeyHex);66- try {67- const base = await buildLocalIdentity(masterKey);68- const vaultId = await computeVaultId(masterKey);69- const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);70- return {71- identity: { ...base, vaultId },72- privateKey: keys.secretKey,73- publicKey: keys.publicKey,74- };75- } finally {76- masterKey.fill(0);77- }78- },79- // A direct code pairing means a physically present user confirmed the SAS80- // on both screens — the strongest consent signal in the system, and the81- // only thing allowed to lift a terminal revocation (see revokeDeviceById).82- persistDevice: async (d) => {83- await clearDeviceRevocation(d.deviceId);84- await upsertPairedDevice(d);85- },86- cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),87- };88-}89-90-// ── Single in-flight pairing per worker ──────────────────────────────────────91-// One pairing at a time. The blocking confirm() resolver is parked here while92-// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.93-let pendingConfirm: ((confirmed: boolean) => void) | null = null;94-let inFlight = false;95-96-function clearPairingState(): void {97- pendingConfirm = null;98- inFlight = false;99-}100-101-export function isCodePairingInFlight(): boolean {102- return inFlight;103-}104-105-/**106- * Kick off a code pairing. Returns immediately; the SAS and the terminal107- * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT108- * pushes. `deps` is injectable for tests.109- */110-export function startCodePairing(111- code: string,112- deps: CodePairingDeps = productionCodePairingDeps(),113-): void {114- inFlight = true;115- const cb: CodePairingCallbacks = {116- onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),117- onComplete: (device) => {118- push("CODE_PAIR_RESULT", { status: "complete", device });119- clearPairingState();120- },121- onError: (error) => {122- push("CODE_PAIR_RESULT", { status: "error", error });123- clearPairingState();124- },125- };126- // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.127- const confirm = (_sas: string): Promise<boolean> =>128- new Promise<boolean>((resolve) => { pendingConfirm = resolve; });129-130- void pairWithCode(code, cb, deps, confirm).finally(() => {131- // onComplete/onError already cleared state; this is the backstop for an132- // unexpected throw that skipped the callbacks.133- if (inFlight) clearPairingState();134- });135-}136-137-/** Resolve the parked confirm() with the user's decision from the popup. */138-export function resolveCodePairingConfirm(confirmed: boolean): void {139- const resolve = pendingConfirm;140- pendingConfirm = null;141- if (resolve) resolve(confirmed);142-}src/background/code-pairing.test.ts+0 −425
@@ -1,425 +0,0 @@1-// Code pairing — the PAIR_IDENTITY half over an address-bearing pairing code.2-//3-// Driven entirely through the dependency-injection seam (CodePairingDeps and a4-// fake PairingSocket), so no real WebSocket or Noise crypto runs — except the5-// SAS KAT, which calls the REAL `derivePairingSAS` to pin cross-implementation6-// agreement with the peer.7-8-import { beforeEach, describe, expect, it, vi } from "vitest";9-10-import {11- pairWithCode,12- type CodePairingDeps,13- type CodePairingCallbacks,14-} from "./code-pairing";15-import type { PairingSocket } from "../shared/pairing-dial";16-17-// REAL SAS implementation — reused, not re-derived (ground truth §5).18-import { derivePairingSAS } from "../shared/pairing-handshake";19-import { encodePairingCode } from "../shared/pairing-code";20-import type { LocalIdentity } from "../shared/pairing-handshake";21-import type { PairedDevice } from "../shared/sync-types";22-23-// ── helpers ────────────────────────────────────────────────────────────────24-25-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));26-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));27-28-const PEER_EPHEMERAL_HEX = "a".repeat(64);29-// The ACK key is the Noise NK responder static, so the encrypted identity must30-// repeat exactly this key after the handshake.31-const PHONE_LONGTERM_HEX = PEER_EPHEMERAL_HEX;32-const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);33-const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);34-35-const OUR_IDENTITY: LocalIdentity = {36- deviceId: "ext-local-001",37- deviceName: "Firefox on MacBook",38- deviceType: "extension",39- longTermPublicKeyHex: "c".repeat(64),40- signingPublicKeyHex: "d".repeat(64),41- vaultId: "e".repeat(64),42-};43-const OUR_PRIVATE_KEY = new Uint8Array(32).fill(0x0c);44-const OUR_PUBLIC_KEY = new Uint8Array(32).fill(0xcc);45-46-/**47- * Scripted fake socket. Captures everything sent, and replays a queued list48- * of inbound frames. A frame of `null` means "the socket closes here with no49- * data" — the wrong-code behaviour (no error frame).50- */51-function makeFakeSocket(inbound: Array<Uint8Array | null>) {52- const sent: Uint8Array[] = [];53- let closed = false;54- let lastWasClose = false;55- const queue = [...inbound];56- const sock: PairingSocket = {57- send(bytes) { sent.push(bytes); },58- async receive() {59- const next = queue.shift();60- if (next === undefined || next === null) {61- closed = true;62- lastWasClose = true;63- throw new Error("socket closed");64- }65- lastWasClose = false;66- return next;67- },68- close() { closed = true; },69- closedBeforeFrame() { return lastWasClose; },70- };71- return { sock, sent, isClosed: () => closed };72-}73-74-/**75- * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we76- * can drive the PAIR_IDENTITY exchange without real crypto. We stub77- * `noiseNKInitiator` via vi.mock below; this is the shape it returns.78- */79-const FAKE_HANDSHAKE_HASH = Uint8Array.from([80- 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),81-]);82-83-// Mock the NK initiator: skip the two handshake messages, hand back a session84-// whose encrypt/decrypt are identity transforms and whose handshakeHash is the85-// cross-platform KAT prefix (so the SAS test below is end-to-end through the flow).86-vi.mock("../shared/noise-js", async (orig) => {87- const actual = await orig<typeof import("../shared/noise-js")>();88- return {89- ...actual,90- noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({91- encrypt: (pt: Uint8Array) => pt,92- decrypt: (ct: Uint8Array) => ct,93- handshakeHash: FAKE_HANDSHAKE_HASH,94- close: vi.fn(),95- })),96- performInitiatorAuth: vi.fn(async () => undefined),97- };98-});99-100-function makeDeps(overrides: Partial<CodePairingDeps> = {}): {101- deps: CodePairingDeps;102- persisted: PairedDevice[];103- sockets: ReturnType<typeof makeFakeSocket>[];104-} {105- const persisted: PairedDevice[] = [];106- const sockets: ReturnType<typeof makeFakeSocket>[] = [];107- const deps: CodePairingDeps = {108- openSocket: vi.fn(async () => {109- // default: ACK then a PAIR_IDENTITY response110- const s = makeFakeSocket([111- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),112- enc({113- type: "PAIR_IDENTITY",114- device_id: "android-001",115- device_name: "Pixel 8",116- device_type: "android",117- long_term_public_key_hex: PHONE_LONGTERM_HEX,118- ws_port: 49142,119- relay_url: "wss://relay.peachpasswords.com",120- }),121- ]);122- sockets.push(s);123- return s.sock;124- }),125- getLocalIdentity: vi.fn(async () => ({126- identity: OUR_IDENTITY,127- privateKey: OUR_PRIVATE_KEY,128- publicKey: OUR_PUBLIC_KEY,129- })),130- getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),131- persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),132- ...overrides,133- };134- return { deps, persisted, sockets };135-}136-137-function makeCallbacks(): {138- cb: CodePairingCallbacks;139- sas: { value?: string };140- errors: string[];141- completed: PairedDevice[];142-} {143- const sas: { value?: string } = {};144- const errors: string[] = [];145- const completed: PairedDevice[] = [];146- const cb: CodePairingCallbacks = {147- onSasReady: (s) => { sas.value = s; },148- onError: (e) => { errors.push(e); },149- onComplete: (d) => { completed.push(d); },150- };151- return { cb, sas, errors, completed };152-}153-154-beforeEach(() => {155- vi.clearAllMocks();156-});157-158-// ── specs ────────────────────────────────────────────────────────────────159-160-describe("pairWithCode", () => {161- it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {162- const { deps, sockets } = makeDeps();163- const { cb } = makeCallbacks();164-165- await pairWithCode(PHONE_CODE, cb, deps, async () => true);166-167- expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);168- const firstFrame = dec(sockets[0].sent[0]);169- expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });170- });171-172- it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {173- // The listener sends NO error frame on a wrong/expired code — it SILENTLY174- // CLOSES the socket and rotates the code. So the175- // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces176- // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)177- const closeImmediately = makeFakeSocket([null]); // close, no data178- const { deps } = makeDeps({179- openSocket: vi.fn(async () => closeImmediately.sock),180- });181- const { cb, errors, completed } = makeCallbacks();182-183- await pairWithCode(PHONE_CODE, cb, deps, async () => true);184-185- expect(errors).toEqual(["invalid_code"]);186- expect(completed).toHaveLength(0);187- expect(deps.persistDevice).not.toHaveBeenCalled();188- });189-190- it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 44907520", () => {191- // Cross-impl KAT, matched byte-for-byte by peach-android's SASDerivation:192- // the first eight bytes 0f ba b8 cd 00 00 00 00 read as an unsigned193- // big-endian integer are 1133421446844907520; mod 1e8 = 44907520.194- const hash = Uint8Array.from([195- 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),196- ]);197- expect(derivePairingSAS(hash)).toBe("44907520");198- });199-200- it("surfaces that same SAS through the pairing flow", async () => {201- // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 44907520.202- const { deps } = makeDeps();203- const { cb, sas } = makeCallbacks();204-205- await pairWithCode(PHONE_CODE, cb, deps, async () => true);206-207- expect(sas.value).toBe("44907520");208- });209-210- it("persists the phone identity on SAS confirm", async () => {211- const { deps, persisted } = makeDeps();212- const { cb, completed, errors } = makeCallbacks();213-214- await pairWithCode(PHONE_CODE, cb, deps, async () => true);215-216- expect(errors).toHaveLength(0);217- expect(persisted).toHaveLength(1);218- expect(persisted[0].deviceType).toBe("android");219- expect(persisted[0].deviceId).toBe("android-001");220- expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);221- expect(persisted[0].trustSource).toBe("direct");222- expect(persisted[0].wsPort).toBe(49142);223- expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");224- expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);225- expect(completed).toHaveLength(1);226- });227-228- it("proves possession before accepting the peer identity", async () => {229- const { performInitiatorAuth } = await import("../shared/noise-js");230- const { deps } = makeDeps();231- const { cb, errors } = makeCallbacks();232-233- await pairWithCode(PHONE_CODE, cb, deps, async () => true);234-235- expect(errors).toHaveLength(0);236- expect(performInitiatorAuth).toHaveBeenCalledTimes(1);237- expect(performInitiatorAuth).toHaveBeenCalledWith(238- expect.anything(),239- OUR_PRIVATE_KEY,240- OUR_PUBLIC_KEY,241- expect.any(Uint8Array),242- expect.any(Function),243- expect.any(Function),244- );245- });246-247- it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => {248- const { deps, persisted } = makeDeps({249- openSocket: vi.fn(async () => makeFakeSocket([250- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),251- enc({252- type: "PAIR_IDENTITY",253- device_id: "attacker",254- device_name: "Wrong key",255- device_type: "android",256- long_term_public_key_hex: "f".repeat(64),257- ws_port: 49142,258- }),259- ]).sock),260- });261- const { cb, errors } = makeCallbacks();262-263- await pairWithCode(PHONE_CODE, cb, deps, async () => true);264-265- expect(errors).toEqual(["handshake_failed"]);266- expect(persisted).toHaveLength(0);267- });268-269- it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {270- const { deps, persisted } = makeDeps();271- const { cb, errors } = makeCallbacks();272-273- await pairWithCode(PHONE_CODE, cb, deps, async () => false);274-275- expect(errors).toEqual(["sas_mismatch"]);276- expect(persisted).toHaveLength(0);277- expect(deps.persistDevice).not.toHaveBeenCalled();278- });279-280- it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {281- const { deps, sockets } = makeDeps();282- const { cb } = makeCallbacks();283-284- await pairWithCode(PHONE_CODE, cb, deps, async () => true);285-286- // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY287- // (encrypt is identity in the mock, so it decodes directly).288- const ourPairIdentity = dec(sockets[0].sent[1]);289- expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");290- expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);291- });292-293- it("reports vault_locked when local identity is unavailable", async () => {294- const { deps } = makeDeps({295- getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),296- });297- const { cb, errors } = makeCallbacks();298-299- await pairWithCode(PHONE_CODE, cb, deps, async () => true);300-301- expect(errors).toEqual(["vault_locked"]);302- expect(deps.openSocket).not.toHaveBeenCalled();303- });304-});305-306-// ── pairing-code path (the phone is the listener) ──────────────────────────307-308-/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */309-function makePhoneDeps(deviceType: string | undefined = "android") {310- const identity: Record<string, unknown> = {311- type: "PAIR_IDENTITY",312- device_id: "android-001",313- device_name: "Pixel 8",314- long_term_public_key_hex: PHONE_LONGTERM_HEX,315- ws_port: 49142,316- relay_url: "wss://relay.peachpasswords.com",317- };318- identity.device_type = deviceType;319- return makeDeps({320- openSocket: vi.fn(async () => makeFakeSocket([321- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),322- enc(identity),323- ]).sock),324- });325-}326-327-describe("pairWithCode — pairing code", () => {328- it("dials the address in the code", async () => {329- const { deps } = makePhoneDeps();330- const { cb, errors } = makeCallbacks();331-332- await pairWithCode(PHONE_CODE, cb, deps, async () => true);333-334- expect(errors).toHaveLength(0);335- expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);336- });337-338- it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {339- const sockets: ReturnType<typeof makeFakeSocket>[] = [];340- const { deps } = makeDeps({341- openSocket: vi.fn(async () => {342- const s = makeFakeSocket([343- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),344- enc({345- type: "PAIR_IDENTITY",346- device_id: "android-001",347- device_name: "Pixel 8",348- device_type: "android",349- long_term_public_key_hex: PHONE_LONGTERM_HEX,350- ws_port: 49142,351- relay_url: "wss://relay.peachpasswords.com",352- }),353- ]);354- sockets.push(s);355- return s.sock;356- }),357- });358- const { cb } = makeCallbacks();359-360- await pairWithCode(PHONE_CODE, cb, deps, async () => true);361-362- const hello = dec(sockets[0].sent[0]);363- expect(hello.type).toBe("PEACH_HELLO");364- expect(hello.token).toBe("deadbeef");365- expect(hello.code).toBeUndefined();366- });367-368- it("persists the phone with the device type it announced", async () => {369- const { deps, persisted } = makePhoneDeps();370- const { cb, completed } = makeCallbacks();371-372- await pairWithCode(PHONE_CODE, cb, deps, async () => true);373-374- expect(persisted).toHaveLength(1);375- expect(persisted[0].deviceType).toBe("android");376- expect(persisted[0].deviceId).toBe("android-001");377- expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);378- expect(persisted[0].trustSource).toBe("direct");379- expect(persisted[0].wsPort).toBe(49142);380- expect(completed).toHaveLength(1);381- });382-383- it("rejects a peer that omits its device type", async () => {384- const { deps, persisted } = makePhoneDeps("");385- const { cb, errors } = makeCallbacks();386-387- await pairWithCode(PHONE_CODE, cb, deps, async () => true);388-389- expect(errors).toEqual(["handshake_failed"]);390- expect(persisted).toHaveLength(0);391- });392-393- it("caches the coded IP for the next sync round", async () => {394- const cacheLastKnownIp = vi.fn(async () => undefined);395- const { deps } = makePhoneDeps();396- deps.cacheLastKnownIp = cacheLastKnownIp;397- const { cb } = makeCallbacks();398-399- await pairWithCode(PHONE_CODE, cb, deps, async () => true);400-401- expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");402- });403-404- it("reports no_host_found when the coded endpoint refuses the connection", async () => {405- const { deps } = makeDeps({406- openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),407- });408- const { cb, errors } = makeCallbacks();409-410- await pairWithCode(PHONE_CODE, cb, deps, async () => true);411-412- expect(errors).toEqual(["no_host_found"]);413- expect(deps.persistDevice).not.toHaveBeenCalled();414- });415-416- it("still aborts on SAS rejection — the token is not the security boundary", async () => {417- const { deps, persisted } = makePhoneDeps();418- const { cb, errors } = makeCallbacks();419-420- await pairWithCode(PHONE_CODE, cb, deps, async () => false);421-422- expect(errors).toEqual(["sas_mismatch"]);423- expect(persisted).toHaveLength(0);424- });425-});src/background/code-pairing.ts+0 −202
@@ -1,202 +0,0 @@1-// Device pairing over a code — the PAIR_IDENTITY half.2-//3-// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4-// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5-// ADOPT_* frames over the same channel instead. This module is only what6-// happens once that channel is open:7-//8-// 1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9-// 2. derive the SAS, surface it for user confirmation10-// 3. on confirm -> persist the PairedDevice; on reject -> abort without11-// persisting12-//13-// A pairing code (`A74H-48J3-KI32-OEP4`) carries the listener's endpoint and14-// one-time token.15-16-import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";17-import type { LocalIdentity } from "../shared/pairing-handshake";18-import type { DeviceType, PairedDevice } from "../shared/sync-types";19-import { bytesToHex, normalizeRelayUrl } from "../shared/rendezvous";20-import { constantTimeEqualHex } from "../shared/constant-time";21-22-/** Default sync port a peer announces; used when ws_port is absent. Same23- * fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */24-const DEFAULT_SYNC_PORT = PAIRING_PORT;25-26-export type CodePairingError =27- | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)28- | "no_host_found" // nothing answered at the endpoint29- | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed30- | "sas_mismatch" // user rejected the SAS31- | "vault_locked"; // no master key available32-33-export interface CodePairingDeps extends PairingDialDeps {34- /**35- * Public identity plus the private key used for structural36- * proof-of-possession. The private half never enters a wire frame.37- */38- getLocalIdentity(): Promise<{39- identity: LocalIdentity;40- privateKey: Uint8Array;41- publicKey: Uint8Array;42- }>;43- /** Current mesh relay URL; sent in the encrypted pairing identity. */44- getRelayUrl(): Promise<string>;45- /** Persist on user confirm. */46- persistDevice(device: PairedDevice): Promise<void>;47- /** Optional fast-path cache for the next sync round. */48- cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49-}50-51-export interface CodePairingCallbacks {52- /** Show the 8-digit SAS; the popup must compare it against the peer's. */53- onSasReady(sas: string): void;54- /** Pairing succeeded and the device was persisted. */55- onComplete(device: PairedDevice): void;56- /** Pairing failed at some stage. */57- onError(error: CodePairingError): void;58-}59-60-interface PairingFrame {61- type: "PAIR_IDENTITY";62- device_id: string;63- device_name: string;64- device_type: DeviceType;65- long_term_public_key_hex: string;66- signing_public_key_hex?: string;67- ws_port?: number;68- vault_id?: string; // Change 1 — additive, optional69- relay_url?: string;70-}71-72-function encodeJson(obj: unknown): Uint8Array {73- return new TextEncoder().encode(JSON.stringify(obj));74-}75-76-function decodePairIdentity(bytes: Uint8Array): PairingFrame {77- const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;78- if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");79- if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");80- if (typeof p.device_name !== "string") throw new Error("device_name missing");81- if (82- p.device_type !== "extension" &&83- p.device_type !== "android" &&84- p.device_type !== "ios"85- ) {86- throw new Error("device_type invalid");87- }88- if (typeof p.long_term_public_key_hex !== "string" ||89- !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {90- throw new Error("long_term_public_key_hex is not 32-byte hex");91- }92- if (p.relay_url !== undefined) {93- if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");94- p.relay_url = normalizeRelayUrl(p.relay_url);95- }96- return p as unknown as PairingFrame;97-}98-99-/**100- * Drive a complete code pairing.101- *102- * `confirm` is a function the caller resolves AFTER the user has compared the103- * SAS shown here against the SAS the peer displays. Resolving false (or104- * rejecting) aborts before any device is persisted — this is the out-of-band105- * MITM defence (see the SECURITY note in pairing-handshake.ts), and it, not the106- * pairing token, is what actually authenticates the peer.107- */108-export async function pairWithCode(109- code: string,110- cb: CodePairingCallbacks,111- deps: CodePairingDeps,112- confirm: (sas: string) => Promise<boolean>,113-): Promise<void> {114- let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;115- try {116- local = await deps.getLocalIdentity();117- } catch {118- cb.onError("vault_locked");119- return;120- }121-122- const dial = await dialPairingChannel(code, deps);123- if (!dial.ok) {124- cb.onError(dial.error);125- return;126- }127- const {128- channel,129- sas,130- host,131- rendezvousSecretHex,132- remoteStaticPublicKey,133- authenticateInitiator,134- socket,135- } = dial.dialled;136-137- try {138- const { identity, privateKey, publicKey } = local;139- if (!constantTimeEqualHex(bytesToHex(publicKey), identity.longTermPublicKeyHex)) {140- throw new Error("local pairing key does not match advertised identity");141- }142- // E1: prove the private half of the advertised initiator identity before143- // either side trusts a PAIR_IDENTITY frame or asks the user for SAS consent.144- await authenticateInitiator(privateKey, publicKey);145-146- const relayUrl = await deps.getRelayUrl();147- // Initiator (browser) sends its identity FIRST, then reads the peer's.148- const ourFrame: PairingFrame = {149- type: "PAIR_IDENTITY",150- device_id: identity.deviceId,151- device_name: identity.deviceName,152- device_type: identity.deviceType,153- long_term_public_key_hex: identity.longTermPublicKeyHex,154- signing_public_key_hex: identity.signingPublicKeyHex,155- ws_port: identity.wsPort,156- vault_id: identity.vaultId, // Change 1157- relay_url: relayUrl,158- };159- await channel.send(encodeJson(ourFrame));160-161- const theirFrame = decodePairIdentity(await channel.receive());162- if (!constantTimeEqualHex(163- theirFrame.long_term_public_key_hex,164- bytesToHex(remoteStaticPublicKey),165- )) {166- throw new Error("peer identity does not match Noise responder key");167- }168-169- cb.onSasReady(sas);170- const confirmed = await confirm(sas);171- if (!confirmed) {172- cb.onError("sas_mismatch");173- return;174- }175-176- // ws_port comes from the authenticated peer frame and falls back to the177- // native listener's fixed pairing/sync port for older peers.178- const device: PairedDevice = {179- deviceId: theirFrame.device_id,180- deviceName: theirFrame.device_name,181- deviceType: theirFrame.device_type,182- longTermPublicKeyHex: theirFrame.long_term_public_key_hex,183- signingPublicKeyHex: theirFrame.signing_public_key_hex,184- trustSource: "direct",185- wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,186- rendezvousSecretHex,187- // The listening phone is the authority for the relay this pair will use.188- relayUrl: theirFrame.relay_url ?? relayUrl,189- pairedAt: Date.now(),190- };191- await deps.persistDevice(device);192- if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {193- await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);194- }195- cb.onComplete(device);196- } catch (err) {197- if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);198- cb.onError("handshake_failed");199- } finally {200- socket.close();201- }202-}src/background/device-identity.ts+37 −0
@@ -15,11 +15,14 @@1515 // only the public half to callers that don't need to sign.1616 1717 import {18+ computeVaultId,1819 deriveSyncIdentityKeypair,1920 deriveSyncSigningKeypair,2021 pubkeyToHex,2122 } from "../shared/sync-identity";23+import type { LocalIdentity } from "../shared/pairing-handshake";2224 import type { DeviceType } from "../shared/sync-types";25+import { getRootKeyHex, isUnlocked } from "./vault";2326 2427 const DEVICE_ID_KEY = "peach_device_id";2528 const DEVICE_NAME_KEY = "peach_device_name";@@ -140,4 +143,38 @@140143 bytes[8] = (bytes[8] & 0x3f) | 0x80;141144 const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");142145 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;146+}147+148+/**149+ * The full local pairing identity plus its sync keypair, derived from the150+ * stable ROOT key so pairing survives a master-password change. Callers own151+ * the returned key material and must zero it.152+ *153+ * Lived in `code-pairing-bg.ts` until LAN code pairing was retired154+ * (docs/sync-lan-retirement.md); it was never LAN-specific.155+ */156+export async function getLocalPairingIdentity(): Promise<{157+ identity: LocalIdentity;158+ privateKey: Uint8Array;159+ publicKey: Uint8Array;160+}> {161+ if (!isUnlocked()) throw new Error("vault_locked");162+ const masterKeyHex = getRootKeyHex();163+ if (!masterKeyHex) throw new Error("vault_locked");164+ const masterKey = new Uint8Array(masterKeyHex.length / 2);165+ for (let i = 0; i < masterKey.length; i++) {166+ masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16);167+ }168+ try {169+ const base = await buildLocalIdentity(masterKey);170+ const vaultId = await computeVaultId(masterKey);171+ const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);172+ return {173+ identity: { ...base, vaultId },174+ privateKey: keys.secretKey,175+ publicKey: keys.publicKey,176+ };177+ } finally {178+ masterKey.fill(0);179+ }143180 }src/background/messages.ts+26 −43
@@ -84,23 +84,18 @@8484 // a worker either — so the real reason was replaced by "window is not defined"8585 // and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails8686 // the build if a runtime import() reappears anywhere in the worker's graph.87-import { parsePairingCode } from "../shared/pairing-code";8887 import {89- isCodePairingInFlight,90- resolveCodePairingConfirm,91- startCodePairing,92-} from "./code-pairing-bg";93-import {9488 cancelRelayAdoptionSource,9589 confirmRelayAdoptionSource,9690 isRelayAdoptionSourceInFlight,9791 startRelayAdoptionSource,9892 } from "./relay-adoption-source-bg";9993 import {100- getRelaySettings,101- setLanOnly,102- setRelayUrl,103-} from "./relay-settings";94+ getSyncSettings,95+ setPrivateRelayUrl,96+ setSyncMode,97+ type SyncMode,98+} from "./sync-settings";10499 import {105100 getSyncState,106101 hydratePersistedSyncState,@@ -1464,27 +1459,10 @@14641459 return { ok: true };14651460 }14661461 1467- // ── Device code pairing ───────────────────────────────────────────────1462+ // ── Relay pairing ─────────────────────────────────────────────────────14681463 // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1469- // pairing runs in the background; the SAS and the terminal result reach the1470- // popup as CODE_PAIR_SAS / CODE_PAIR_RESULT pushes.1471- case "PAIR_WITH_CODE": {1472- if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1473- const { code } = (msg.payload ?? {}) as { code?: string };1474- if (typeof code !== "string" || parsePairingCode(code) === null) {1475- return { ok: false, error: "invalid_payload" };1476- }1477- if (isCodePairingInFlight()) return { ok: false, error: "pairing_in_progress" };1478- startCodePairing(code);1479- return { ok: true };1480- }1481-1482- case "CODE_PAIR_CONFIRM": {1483- const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1484- resolveCodePairingConfirm(confirmed === true);1485- return { ok: true };1486- }1487-1464+ // session runs in the background; the SAS and the terminal result reach1465+ // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.14881466 case "START_ADOPTION_SOURCE": {14891467 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14901468 if (isRelayAdoptionSourceInFlight()) {@@ -1540,31 +1518,36 @@15401518 return { ok: true, data: getSyncState() };15411519 }15421520 1543- case "GET_RELAY_SETTINGS": {1544- return { ok: true, data: await getRelaySettings() };1521+ case "GET_SYNC_SETTINGS": {1522+ return { ok: true, data: await getSyncSettings() };15451523 }15461524 1547- case "SET_RELAY_SETTINGS": {1525+ case "SET_SYNC_SETTINGS": {15481526 const payload = (msg.payload ?? {}) as {1549- relayUrl?: string;1550- lanOnly?: boolean;1527+ mode?: unknown;1528+ relayUrl?: unknown;15511529 };15521530 try {1531+ // A relay URL implies private mode — offering a custom relay and not1532+ // switching to it would be a trap.15531533 if (payload.relayUrl !== undefined) {15541534 if (typeof payload.relayUrl !== "string") {15551535 return { ok: false, error: "invalid_relay_url" };15561536 }1557- await setRelayUrl(payload.relayUrl);1537+ return { ok: true, data: await setPrivateRelayUrl(payload.relayUrl) };15581538 }1559- if (payload.lanOnly !== undefined) {1560- if (typeof payload.lanOnly !== "boolean") {1561- return { ok: false, error: "invalid_lan_only" };1539+ if (payload.mode !== undefined) {1540+ if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1541+ return { ok: false, error: "invalid_sync_mode" };15621542 }1563- await setLanOnly(payload.lanOnly);1543+ return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };15641544 }1565- return { ok: true, data: await getRelaySettings() };1566- } catch {1567- return { ok: false, error: "invalid_relay_url" };1545+ return { ok: true, data: await getSyncSettings() };1546+ } catch (error) {1547+ return {1548+ ok: false,1549+ error: error instanceof Error ? error.message : "invalid_relay_url",1550+ };15681551 }15691552 }15701553 src/background/relay-adoption-source-bg.test.ts+16 −7
@@ -31,17 +31,26 @@3131 };3232 });3333 34-vi.mock("./code-pairing-bg", () => ({35- productionCodePairingDeps: () => ({36- getLocalIdentity: mocks.getLocalIdentity,34+35+vi.mock("./device-identity", () => ({36+ getLocalPairingIdentity: async () => ({37+ identity: {38+ deviceId: "test-device",39+ deviceName: "Test",40+ deviceType: "extension",41+ longTermPublicKeyHex: "aa".repeat(32),42+ vaultId: "bb".repeat(32),43+ },44+ privateKey: new Uint8Array(32).fill(7),45+ publicKey: new Uint8Array(32).fill(8),3746 }),3847 }));3948 40-vi.mock("./relay-settings", () => ({41- getRelaySettings: vi.fn(async () => ({49+vi.mock("./sync-settings", () => ({50+ getSyncSettings: async () => ({51+ mode: "public" as const,4252 relayUrl: "wss://relay.peachpasswords.com",43- lanOnly: false,44- })),53+ }),4554 }));4655 4756 vi.mock("./license", () => ({src/background/relay-adoption-source-bg.ts+8 −8
@@ -26,8 +26,8 @@2626 deriveRendezvousSecret,2727 generatePairingRendezvousToken,2828 } from "../shared/rendezvous";29-import { productionCodePairingDeps } from "./code-pairing-bg";30-import { getRelaySettings } from "./relay-settings";29+import { getLocalPairingIdentity } from "./device-identity";30+import { getSyncSettings } from "./sync-settings";3131 import { getLicenseKey, isProUser } from "./license";3232 import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";3333 import { computeVaultId } from "../shared/sync-identity";@@ -66,12 +66,12 @@6666 if (!isUnlocked()) throw new Error("vault_locked");6767 inFlight = true;6868 69- let local: Awaited<70- ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>71- > | null = null;69+ let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;7270 try {73- local = await productionCodePairingDeps().getLocalIdentity();74- const relayUrl = (await getRelaySettings()).relayUrl;71+ const settings = await getSyncSettings();72+ if (settings.mode === "off") throw new Error("sync_disabled");73+ local = await getLocalPairingIdentity();74+ const relayUrl = settings.relayUrl;7575 const token = generatePairingRendezvousToken();7676 const encoded = createPairingInvitation(relayUrl, token);7777 const invitation = parsePairingInvitation(encoded);@@ -131,7 +131,7 @@131131 socket: PairingSocket,132132 expectedToken: string,133133 relayUrl: string,134- local: Awaited<ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>>,134+ local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,135135 ): Promise<void> {136136 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);137137 let noise: NoiseNKSession | undefined;src/background/relay-settings.test.ts+0 −75
@@ -1,75 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-import {4- getRelaySettings,5- setLanOnly,6- setRelayUrl,7-} from "./relay-settings";8-import { DEFAULT_RELAY_URL } from "../shared/rendezvous";9-10-let stored: Record<string, unknown>;11-12-beforeEach(() => {13- stored = {};14- (globalThis as { chrome?: unknown }).chrome = {15- storage: {16- local: {17- get: vi.fn(async (keys: string | string[]) => {18- const requested = Array.isArray(keys) ? keys : [keys];19- return Object.fromEntries(20- requested21- .filter((key) => key in stored)22- .map((key) => [key, stored[key]]),23- );24- }),25- set: vi.fn(async (values: Record<string, unknown>) => {26- Object.assign(stored, values);27- }),28- },29- },30- };31-});32-33-describe("relay settings", () => {34- it("defaults to June's relay with internet fallback enabled", async () => {35- await expect(getRelaySettings()).resolves.toEqual({36- relayUrl: DEFAULT_RELAY_URL,37- lanOnly: false,38- });39- });40-41- it("persists a normalized self-hosted wss URL and LAN-only opt-out", async () => {42- stored.peach_paired_devices_v3 = [43- {44- deviceId: "phone",45- deviceName: "Phone",46- deviceType: "android",47- longTermPublicKeyHex: "0".repeat(64),48- pairedAt: 1,49- relayUrl: "wss://old-relay.example",50- },51- ];52- await expect(setRelayUrl("wss://relay.example.test/base/")).resolves.toBe(53- "wss://relay.example.test/base",54- );55- await setLanOnly(true);56- await expect(getRelaySettings()).resolves.toEqual({57- relayUrl: "wss://relay.example.test/base",58- lanOnly: true,59- });60- expect(stored.peach_paired_devices_v3).toEqual([61- expect.objectContaining({62- deviceId: "phone",63- relayUrl: "wss://relay.example.test/base",64- }),65- ]);66- });67-68- it("rejects ws and ignores an invalid stored URL", async () => {69- await expect(setRelayUrl("ws://relay.example.test")).rejects.toThrow(/wss/);70- stored.peach_relay_url_v1 = "https://not-a-websocket.example";71- await expect(getRelaySettings()).resolves.toMatchObject({72- relayUrl: DEFAULT_RELAY_URL,73- });74- });75-});src/background/relay-settings.ts+0 −46
@@ -1,46 +0,0 @@1-import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2-import { setRelayUrlForAllPairedDevices } from "./paired-devices";3-4-const RELAY_URL_KEY = "peach_relay_url_v1";5-const LAN_ONLY_KEY = "peach_sync_lan_only_v1";6-7-export interface RelaySettings {8- relayUrl: string;9- lanOnly: boolean;10-}11-12-export async function getRelaySettings(): Promise<RelaySettings> {13- try {14- const raw = await chrome.storage.local.get([RELAY_URL_KEY, LAN_ONLY_KEY]);15- const values = raw as Record<string, unknown>;16- const relayUrl =17- typeof values[RELAY_URL_KEY] === "string"18- ? safeNormalize(values[RELAY_URL_KEY] as string)19- : DEFAULT_RELAY_URL;20- return {21- relayUrl,22- lanOnly: values[LAN_ONLY_KEY] === true,23- };24- } catch {25- return { relayUrl: DEFAULT_RELAY_URL, lanOnly: false };26- }27-}28-29-export async function setRelayUrl(input: string): Promise<string> {30- const relayUrl = normalizeRelayUrl(input);31- await setRelayUrlForAllPairedDevices(relayUrl);32- await chrome.storage.local.set({ [RELAY_URL_KEY]: relayUrl });33- return relayUrl;34-}35-36-export async function setLanOnly(lanOnly: boolean): Promise<void> {37- await chrome.storage.local.set({ [LAN_ONLY_KEY]: lanOnly });38-}39-40-function safeNormalize(input: string): string {41- try {42- return normalizeRelayUrl(input);43- } catch {44- return DEFAULT_RELAY_URL;45- }46-}src/background/sync-runner.test.ts+19 −182
@@ -2,7 +2,6 @@22 import type { PairedDevice, SyncMessage } from "../shared/sync-types";33 import type {44 SyncSession,5- SyncEndpoint,65 } from "../shared/sync-transport";76 import type { SyncVaultAdapter } from "./sync-coordinator";87 import type { SyncRunnerDeps } from "./sync-runner";@@ -172,6 +171,11 @@172171 };173172 }174173 174+const relaySettings = async () => ({175+ mode: "public" as const,176+ relayUrl: "wss://relay.peachpasswords.com",177+});178+175179 describe("productionSyncRunner", () => {176180 let local: StorageShape;177181 let sync: StorageShape;@@ -195,23 +199,21 @@195199 vault.lock();196200 const { runWithDeps } = await import("./sync-runner");197201 const deps: SyncRunnerDeps = {198- openSession: vi.fn(),199- probeSubnet: vi.fn(),200- getSubnet: vi.fn(async () => "192.168.1"),202+ openRelaySession: vi.fn(),203+ getSyncSettings: relaySettings,201204 createAdapter: emptyAdapter,202205 };203206 const result = await runWithDeps(makeDevice(), deps);204207 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205- expect(deps.openSession).not.toHaveBeenCalled();208+ expect(deps.openRelaySession).not.toHaveBeenCalled();206209 });207210 208211 it("throws when the device has no longTermPublicKeyHex", async () => {209212 await bootVault();210213 const { runWithDeps } = await import("./sync-runner");211214 const deps: SyncRunnerDeps = {212- openSession: vi.fn(),213- probeSubnet: vi.fn(),214- getSubnet: vi.fn(),215+ openRelaySession: vi.fn(),216+ getSyncSettings: relaySettings,215217 createAdapter: emptyAdapter,216218 };217219 await expect(@@ -223,9 +225,8 @@223225 await bootVault();224226 const { runWithDeps } = await import("./sync-runner");225227 const deps: SyncRunnerDeps = {226- openSession: vi.fn(),227- probeSubnet: vi.fn(),228- getSubnet: vi.fn(),228+ openRelaySession: vi.fn(),229+ getSyncSettings: relaySettings,229230 createAdapter: emptyAdapter,230231 };231232 await expect(@@ -233,170 +234,16 @@233234 ).rejects.toThrow("paired_device_missing_rendezvous_secret");234235 });235236 236- it("uses the cached last-known IP first and skips the subnet probe", async () => {237+ it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {237238 await bootVault();238- const { setLastKnownIp } = await import("./paired-devices");239- await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");240-241239 const { session, peer } = inMemoryPair();242- const openSession = vi.fn(async (_e: SyncEndpoint) => session);243- const probeSubnet = vi.fn();240+ const openRelaySession = vi.fn(async () => session);244241 const deps: SyncRunnerDeps = {245- openSession,246- probeSubnet,247- getSubnet: vi.fn(async () => "192.168.1"),248- createAdapter: emptyAdapter,249- };250-251- const { runWithDeps } = await import("./sync-runner");252- const runPromise = runWithDeps(makeDevice(), deps);253- await runPeer(peer);254- const result = await runPromise;255- expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });256- expect(openSession).toHaveBeenCalledTimes(1);257- expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });258- expect(probeSubnet).not.toHaveBeenCalled();259- });260-261- it("falls back to subnet probe when cached IP fails to connect", async () => {262- await bootVault();263- const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");264- await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");265-266- const { session, peer } = inMemoryPair();267- const openSession = vi.fn()268- .mockRejectedValueOnce(new Error("ECONNREFUSED"))269- .mockRejectedValueOnce(new Error("ECONNREFUSED"))270- .mockRejectedValueOnce(new Error("ECONNREFUSED"))271- .mockResolvedValueOnce(session);272- const probeSubnet = vi.fn(async (273- ...args: Parameters<SyncRunnerDeps["probeSubnet"]>274- ) => ({275- ip: "192.168.1.99",276- port: 49142,277- rendezvousId: args[4][2],278- }));279- const deps: SyncRunnerDeps = {280- openSession,281- probeSubnet,282- getSubnet: vi.fn(async () => "192.168.1"),283- createAdapter: emptyAdapter,284- };285-286- const { runWithDeps } = await import("./sync-runner");287- const runPromise = runWithDeps(makeDevice(), deps);288- await runPeer(peer);289- await runPromise;290- expect(openSession).toHaveBeenCalledTimes(4);291- expect(openSession.mock.calls[3][0]).toEqual({ ip: "192.168.1.99", port: 49142 });292- expect(openSession.mock.calls[3][3]).toBe(probeSubnet.mock.calls[0][4][2]);293- expect(probeSubnet).toHaveBeenCalledTimes(1);294- expect(probeSubnet.mock.calls[0][4]).toHaveLength(3);295- expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");296- });297-298- it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {299- await bootVault();300- const { setLastKnownIp } = await import("./paired-devices");301- await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");302-303- const deps: SyncRunnerDeps = {304- openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),305- probeSubnet: vi.fn(async () => null),306- getSubnet: vi.fn(async () => "192.168.1"),307- createAdapter: emptyAdapter,308- };309- const { runWithDeps } = await import("./sync-runner");310- await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");311- });312-313- it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {314- await bootVault();315- const deps: SyncRunnerDeps = {316- openSession: vi.fn(),317- probeSubnet: vi.fn(),318- getSubnet: vi.fn(async () => null),319- createAdapter: emptyAdapter,320- };321- const { runWithDeps } = await import("./sync-runner");322- await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");323- });324-325- it("falls back to the configured relay after LAN discovery fails", async () => {326- await bootVault();327- const { session, peer } = inMemoryPair();328- const openRelaySession = vi.fn()329- .mockRejectedValueOnce(new Error("sync_handshake_timeout"))330- .mockResolvedValueOnce(session);331- const deps: SyncRunnerDeps = {332- openSession: vi.fn(),333- probeSubnet: vi.fn(),334242 openRelaySession,335- getRelaySettings: vi.fn(async () => ({336- relayUrl: "wss://relay.example.test",337- lanOnly: false,338- })),339- getSubnet: vi.fn(async () => null),243+ getSyncSettings: relaySettings,340244 createAdapter: emptyAdapter,341245 };342-343246 const { runWithDeps } = await import("./sync-runner");344- const runPromise = runWithDeps(345- makeDevice({ relayUrl: "wss://paired-relay.example.test" }),346- deps,347- );348- await runPeer(peer);349- await expect(runPromise).resolves.toEqual({350- ok: true,351- entriesReceived: 0,352- entriesSent: 0,353- });354- expect(openRelaySession).toHaveBeenCalledTimes(2);355- expect(openRelaySession.mock.calls[0][0]).toBe(356- "wss://paired-relay.example.test",357- );358- expect(openRelaySession.mock.calls[0][3]).not.toBe(359- openRelaySession.mock.calls[1][3],360- );361- });362-363- it("never opens a relay session in LAN-only mode", async () => {364- await bootVault();365- const openRelaySession = vi.fn();366- const deps: SyncRunnerDeps = {367- openSession: vi.fn(),368- probeSubnet: vi.fn(),369- openRelaySession,370- getRelaySettings: vi.fn(async () => ({371- relayUrl: "wss://relay.example.test",372- lanOnly: true,373- })),374- getSubnet: vi.fn(async () => null),375- createAdapter: emptyAdapter,376- };377-378- const { runWithDeps } = await import("./sync-runner");379- await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow(380- "subnet_unknown",381- );382- expect(openRelaySession).not.toHaveBeenCalled();383- });384-385- it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {386- await bootVault();387- const { session, peer } = inMemoryPair();388- const openSession = vi.fn(async () => session);389- const deps: SyncRunnerDeps = {390- openSession,391- probeSubnet: vi.fn(async () => ({392- ip: "192.168.1.1",393- port: 49142,394- rendezvousId: "A".repeat(43),395- })),396- getSubnet: vi.fn(async () => "192.168.1"),397- createAdapter: emptyAdapter,398- };399- const { runWithDeps } = await import("./sync-runner");400247 const runPromise = runWithDeps(makeDevice(), deps);401248 await runPeer(peer, {402249 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },@@ -421,13 +268,8 @@421268 },422269 };423270 const deps: SyncRunnerDeps = {424- openSession: vi.fn(async () => session),425- probeSubnet: vi.fn(async () => ({426- ip: "192.168.1.1",427- port: 49142,428- rendezvousId: "A".repeat(43),429- })),430- getSubnet: vi.fn(async () => "192.168.1"),271+ openRelaySession: vi.fn(async () => session),272+ getSyncSettings: relaySettings,431273 createAdapter: () => adapter,432274 };433275 const { runWithDeps } = await import("./sync-runner");@@ -442,13 +284,8 @@442284 await bootVault();443285 const { session, peer } = inMemoryPair();444286 const deps: SyncRunnerDeps = {445- openSession: vi.fn(async () => session),446- probeSubnet: vi.fn(async () => ({447- ip: "192.168.1.1",448- port: 49142,449- rendezvousId: "A".repeat(43),450- })),451- getSubnet: vi.fn(async () => "192.168.1"),287+ openRelaySession: vi.fn(async () => session),288+ getSyncSettings: relaySettings,452289 createAdapter: emptyAdapter,453290 };454291 const { runWithDeps } = await import("./sync-runner");src/background/sync-runner.ts+18 −121
@@ -5,13 +5,10 @@55 // surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in66 // sync-trigger.ts iterates over hosts and aggregates results.77 //8-// Connection strategy:9-// 1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10-// 2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11-// probe is itself rate-limited (60s per-pubkey cooldown) inside the12-// transport, so we don't add another layer here.13-// 3. On a successful session, persist the IP we connected on so the next14-// sync round skips the probe.8+// Connection strategy: dial the mesh's relay, trying each clock-skew9+// rendezvous window in turn. There is one transport. LAN peer-to-peer sync is10+// retired (docs/sync-lan-retirement.md), so there is no address cache, no11+// subnet probe and no fallback ladder to reason about.1512 //1613 // We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange1714 // does that (returns `status: "vault_mismatch"` if the peer holds a@@ -21,22 +18,12 @@2118 import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";2219 import { createSyncVaultAdapter } from "./sync-vault-adapter";2320 import {24- getLastKnownIp,25- setLastKnownIp,26-} from "./paired-devices";27-import {2821 openRelaySyncSession,29- openSyncSession,30- probeSubnetForPeer,31- type ProbedSyncEndpoint,3222 type SyncSession,33- type SyncEndpoint,3423 type SyncPeerIdentity,3524 } from "../shared/sync-transport";3625 import { hexToPubkey } from "../shared/sync-identity";37-import { getLocalSubnet } from "../shared/local-network";3826 import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";39-import { PAIRING_PORT } from "../shared/pairing-dial";4027 import { constantTimeEqualHex } from "../shared/constant-time";4128 import { getRootKeyHex, isUnlocked } from "./vault";4229 import type { PairedDevice } from "../shared/sync-types";@@ -45,52 +32,27 @@4532 dialingRendezvousIds,4633 rendezvousSecretFromHex,4734 } from "../shared/rendezvous";48-import {49- getRelaySettings,50- type RelaySettings,51-} from "./relay-settings";35+import { getSyncSettings, type SyncSettings } from "./sync-settings";5236 53-/** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */54-const DEFAULT_PORT = PAIRING_PORT;55-5637 /**5738 * Test/injection seam. Production wires in the real `openSyncSession`;5839 * unit tests inject a fake to avoid real WebSocket + Noise KK.5940 */6041 export interface SyncRunnerDeps {61- openSession: (62- endpoint: SyncEndpoint,63- ourIdentity: SyncPeerIdentity,64- expectedRemoteHex: string,65- rendezvousId: string,66- ) => Promise<SyncSession>;67- probeSubnet: (68- subnet: string,69- port: number,70- ourIdentity: SyncPeerIdentity,71- expectedRemoteHex: string,72- rendezvousIds: readonly string[],73- ) => Promise<ProbedSyncEndpoint | null>;74- openRelaySession?: (42+ openRelaySession: (7543 relayUrl: string,7644 ourIdentity: SyncPeerIdentity,7745 expectedRemoteHex: string,7846 rendezvousId: string,7947 ) => Promise<SyncSession>;80- getRelaySettings?: () => Promise<RelaySettings>;81- getSubnet: () => Promise<string | null>;48+ getSyncSettings: () => Promise<SyncSettings>;8249 createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;8350 }8451 8552 const productionDeps: SyncRunnerDeps = {86- openSession: (endpoint, identity, expected, rendezvousId) =>87- openSyncSession(endpoint, identity, expected, rendezvousId),88- probeSubnet: (subnet, port, identity, expected, rendezvousIds) =>89- probeSubnetForPeer(subnet, port, identity, expected, rendezvousIds),9053 openRelaySession: (relayUrl, identity, expected, rendezvousId) =>9154 openRelaySyncSession(relayUrl, identity, expected, rendezvousId),92- getRelaySettings,93- getSubnet: getLocalSubnet,55+ getSyncSettings,9456 createAdapter: (context) => createSyncVaultAdapter(context),9557 };9658 @@ -117,8 +79,6 @@11779 rendezvousSecretFromHex(device.rendezvousSecretHex),11880 Date.now() / 1000,11981 );120- const port = device.wsPort ?? DEFAULT_PORT;121-12282 // Sync identity derives from the stable root key, not the password-derived12383 // key. Reconnect uses both halves directly in standard Noise KK.12484 const masterKeyHex = getRootKeyHex();@@ -146,31 +106,15 @@146106 const adapter = deps.createAdapter({ deviceName: device.deviceName });147107 let session: SyncSession;148108 try {149- try {150- session = await connectLan(151- expectedRemoteHex,152- port,153- ourIdentity,154- reconnectIds,155- deps,156- );157- } catch (lanError) {158- const relaySettings = await deps.getRelaySettings?.();159- if (160- !relaySettings ||161- relaySettings.lanOnly ||162- !deps.openRelaySession163- ) {164- throw lanError;165- }166- session = await connectRelay(167- device.relayUrl ?? relaySettings.relayUrl,168- expectedRemoteHex,169- ourIdentity,170- reconnectIds,171- deps.openRelaySession,172- );173- }109+ const settings = await deps.getSyncSettings();110+ if (settings.mode === "off") throw new Error("sync_disabled");111+ session = await connectRelay(112+ device.relayUrl ?? settings.relayUrl,113+ expectedRemoteHex,114+ ourIdentity,115+ reconnectIds,116+ deps.openRelaySession,117+ );174118 } finally {175119 masterKey.fill(0);176120 localKeys.secretKey.fill(0);@@ -199,59 +143,12 @@199143 }200144 }201145 202-async function connectLan(203- expectedRemoteHex: string,204- port: number,205- ourIdentity: SyncPeerIdentity,206- rendezvousIds: string[],207- deps: SyncRunnerDeps,208-): Promise<SyncSession> {209- const cached = await getLastKnownIp(expectedRemoteHex);210- if (cached) {211- for (const rendezvousId of rendezvousIds) {212- try {213- const session = await deps.openSession(214- { ip: cached, port },215- ourIdentity,216- expectedRemoteHex,217- rendezvousId,218- );219- return session;220- } catch {221- // Try the next clock-skew window before treating the IP as stale.222- }223- }224- }225-226- const subnet = await deps.getSubnet();227- if (!subnet) {228- throw new Error(cached ? "host_unreachable" : "subnet_unknown");229- }230-231- const found = await deps.probeSubnet(232- subnet,233- port,234- ourIdentity,235- expectedRemoteHex,236- rendezvousIds,237- );238- if (!found) throw new Error("host_unreachable");239- const session = await deps.openSession(240- { ip: found.ip, port: found.port },241- ourIdentity,242- expectedRemoteHex,243- found.rendezvousId,244- );245- await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);246- return session;247-}248-249146 async function connectRelay(250147 relayUrl: string,251148 expectedRemoteHex: string,252149 ourIdentity: SyncPeerIdentity,253150 rendezvousIds: string[],254- openRelaySession: NonNullable<SyncRunnerDeps["openRelaySession"]>,151+ openRelaySession: SyncRunnerDeps["openRelaySession"],255152 ): Promise<SyncSession> {256153 let lastError: unknown;257154 for (const rendezvousId of rendezvousIds) {src/background/sync-settings.ts+105 −0
@@ -0,0 +1,105 @@1+import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2+import { setRelayUrlForAllPairedDevices } from "./paired-devices";3+4+/**5+ * Sync is exactly three named choices (docs/sync-lan-retirement.md):6+ *7+ * - `public` — Peach's relay. The default, zero setup.8+ * - `private` — a self-hosted peach-relay the user points their devices at.9+ * - `off` — no sync; the vault moves between devices by Codex/CSV export.10+ *11+ * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and12+ * no "local network only" opt-out. `public` and `private` differ only in which13+ * relay URL is in force, so the transport below them is identical.14+ */15+export type SyncMode = "public" | "private" | "off";16+17+export interface SyncSettings {18+ mode: SyncMode;19+ /**20+ * The relay in force. Always `DEFAULT_RELAY_URL` under `public`; the user's21+ * URL under `private`. Retained (not blanked) under `off` so turning sync22+ * back on does not lose a configured private relay.23+ */24+ relayUrl: string;25+}26+27+const RELAY_URL_KEY = "peach_relay_url_v1";28+const SYNC_MODE_KEY = "peach_sync_mode_v1";29+/** Retired `lanOnly` flag, read once for migration and never written again. */30+const LEGACY_LAN_ONLY_KEY = "peach_sync_lan_only_v1";31+32+export async function getSyncSettings(): Promise<SyncSettings> {33+ try {34+ const values = (await chrome.storage.local.get([35+ RELAY_URL_KEY,36+ SYNC_MODE_KEY,37+ LEGACY_LAN_ONLY_KEY,38+ ])) as Record<string, unknown>;39+40+ const storedUrl =41+ typeof values[RELAY_URL_KEY] === "string"42+ ? safeNormalize(values[RELAY_URL_KEY] as string)43+ : DEFAULT_RELAY_URL;44+45+ const stored = values[SYNC_MODE_KEY];46+ if (stored === "public" || stored === "private" || stored === "off") {47+ return { mode: stored, relayUrl: relayFor(stored, storedUrl) };48+ }49+50+ // Migration. Someone who set lanOnly chose "no relay involved", and LAN is51+ // now gone, so the honest landing spot is `off` rather than silently52+ // enrolling them in a relay they opted out of. Everyone else lands on53+ // public/private by whether they had configured a custom URL.54+ const mode: SyncMode =55+ values[LEGACY_LAN_ONLY_KEY] === true56+ ? "off"57+ : storedUrl === DEFAULT_RELAY_URL58+ ? "public"59+ : "private";60+ return { mode, relayUrl: relayFor(mode, storedUrl) };61+ } catch {62+ return { mode: "public", relayUrl: DEFAULT_RELAY_URL };63+ }64+}65+66+/** True when sync may run at all. */67+export async function isSyncEnabled(): Promise<boolean> {68+ return (await getSyncSettings()).mode !== "off";69+}70+71+export async function setSyncMode(mode: SyncMode): Promise<SyncSettings> {72+ const current = await getSyncSettings();73+ const relayUrl = relayFor(mode, current.relayUrl);74+ // The relay is mesh state: every paired device must agree on it, or a peer75+ // keeps dialling a relay we no longer use.76+ if (mode !== "off") await setRelayUrlForAllPairedDevices(relayUrl);77+ await chrome.storage.local.set({ [SYNC_MODE_KEY]: mode, [RELAY_URL_KEY]: relayUrl });78+ return { mode, relayUrl };79+}80+81+/**82+ * Point this mesh at a self-hosted relay. Implies `private` — choosing a custom83+ * relay and not switching to it would be a trap.84+ */85+export async function setPrivateRelayUrl(input: string): Promise<SyncSettings> {86+ const relayUrl = normalizeRelayUrl(input);87+ await setRelayUrlForAllPairedDevices(relayUrl);88+ await chrome.storage.local.set({89+ [RELAY_URL_KEY]: relayUrl,90+ [SYNC_MODE_KEY]: "private" satisfies SyncMode,91+ });92+ return { mode: "private", relayUrl };93+}94+95+function relayFor(mode: SyncMode, configured: string): string {96+ return mode === "public" ? DEFAULT_RELAY_URL : configured;97+}98+99+function safeNormalize(input: string): string {100+ try {101+ return normalizeRelayUrl(input);102+ } catch {103+ return DEFAULT_RELAY_URL;104+ }105+}src/background/sync-static.test.ts+4 −2
@@ -76,8 +76,10 @@7676 const appSrc = src("../popup/App.tsx");7777 expect(appSrc).not.toContain("openPairingServer");7878 expect(appSrc).not.toContain("chirp-pair-capacitor");79- // Adoption dials out like everything else now.80- expect(appSrc).toContain("dialPairingChannel");79+ // Adoption dials out like everything else now — over the relay only, since80+ // LAN transport is retired (docs/sync-lan-retirement.md).81+ expect(appSrc).toContain("dialRelayPairingChannel");82+ expect(appSrc).not.toContain("openPairingSocket");8183 });8284 8385 it("vault_id uses the full 64-char BLAKE3 hex digest", () => {src/popup/App.tsx+11 −41
@@ -199,7 +199,6 @@199199 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);200200 // When the sync card's pairing button opens the pairing view, deep-link201201 // straight into that pairing path instead of the rest screen.202- const [syncPairIntent, setSyncPairIntent] = createSignal<"code" | null>(null);203202 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");204203 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);205204 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");@@ -1222,40 +1221,21 @@12221221 */12231222 async function startVaultAdoption(args: {12241223 password: string;1225- connection:1226- | { mode: "relay"; invitation: string }1227- | { mode: "lan"; code: string };1224+ connection: { invitation: string };12281225 confirmSAS(sas: string): Promise<boolean>;12291226 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;12301227 }) {1231- const [{1232- dialPairingChannel,1233- dialRelayPairingChannel,1234- openPairingSocket,1235- openRelayPairingSocket,1236- }, { requestAdoption }, { setLastKnownIp }] =1228+ const [{ dialRelayPairingChannel, openRelayPairingSocket }, { requestAdoption }] =12371229 await Promise.all([12381230 import("../shared/pairing-dial"),12391231 import("../shared/vault-adopt"),1240- import("../background/paired-devices"),12411232 ]);12421233 1243- const dial = args.connection.mode === "relay"1244- ? await dialRelayPairingChannel(args.connection.invitation, {1245- openSocket: openRelayPairingSocket,1246- })1247- : await dialPairingChannel(args.connection.code, {1248- openSocket: openPairingSocket,1249- }, { flow: "adoption" });1234+ const dial = await dialRelayPairingChannel(args.connection.invitation, {1235+ openSocket: openRelayPairingSocket,1236+ });12501237 if (!dial.ok) throw new Error(dial.error);1251- const {1252- channel,1253- sas,1254- socket,1255- host,1256- relayUrl,1257- rendezvousSecretHex,1258- } = dial.dialled;1238+ const { channel, sas, socket, relayUrl, rendezvousSecretHex } = dial.dialled;12591239 12601240 try {12611241 const source = await requestAdoption(channel, {@@ -1276,16 +1256,6 @@12761256 const res = await sendMessage({ type: "COMPLETE_PAIRING", payload: { paired } });12771257 const r = res as { ok: boolean; error?: string };12781258 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1279- // Seed the reconnect cache with the address we just dialled, exactly as1280- // device pairing does (code-pairing.ts). Without it an adopted browser1281- // never syncs on Chrome: the runner lives in the MV3 service worker,1282- // its probe fallback needs RTCPeerConnection to find the /24, and that1283- // API does not exist in a service worker — so getSubnet() always1284- // returns null and connect() throws subnet_unknown forever. The cache1285- // is the only route that ever reaches the peer.1286- if (!relayUrl && paired.longTermPublicKeyHex) {1287- await setLastKnownIp(paired.longTermPublicKeyHex, host).catch(() => undefined);1288- }12891259 },12901260 applyLicense: async (license) => {12911261 const res = (await sendMessage({@@ -1302,9 +1272,10 @@13021272 });13031273 if (!source) throw new Error("adoption_rejected");13041274 if (relayUrl) {1275+ // Adopt the source's relay so this device dials the same rendezvous.13051276 const setting = (await sendMessage({1306- type: "SET_RELAY_SETTINGS",1307- payload: { relayUrl, lanOnly: false },1277+ type: "SET_SYNC_SETTINGS",1278+ payload: { relayUrl },13081279 })) as { ok: boolean; error?: string };13091280 if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");13101281 }@@ -1715,7 +1686,7 @@17151686 onExport={() => setView("export")}17161687 onBreachCheckDone={loadEntries}17171688 onOpenHealth={openHealth}1718- onOpenSyncPairing={(method) => { setSettingsInitialSection(settingsActiveCard()); setSyncPairIntent(method ?? null); setView("pair-device"); }}1689+ onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}17191690 onOpenFileImport={handleOpenFileImport}17201691 onOpenPanic={() => setView("panic")}17211692 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}@@ -1756,9 +1727,8 @@17561727 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>17571728 <PairDeviceView17581729 isPro={proStatus()}1759- initialPair={syncPairIntent()}17601730 onProChanged={() => { void refreshProStatus(); }}1761- onBack={() => { setSyncPairIntent(null); setView("settings"); }}1731+ onBack={() => setView("settings")}17621732 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}17631733 />17641734 </Suspense>src/popup/styles/popup.css+46 −0
@@ -10488,3 +10488,49 @@1048810488 line-height: 1.5;1048910489 color: var(--peach-t2);1049010490 }10491+10492+/* Sync mode selector — three named choices (public / private / off). Replaces10493+ the old binary "local network only" toggle; see docs/sync-lan-retirement.md. */10494+.peach-sync-mode {10495+ display: flex;10496+ align-items: flex-start;10497+ gap: 10px;10498+ width: 100%;10499+ text-align: left;10500+ padding: 10px 12px;10501+ margin-top: 8px;10502+ border-radius: 10px;10503+ border: 1px solid var(--peach-border);10504+ background: color-mix(in srgb, var(--peach-t0) 3%, transparent);10505+ color: var(--peach-t1);10506+ cursor: pointer;10507+ transition: border-color var(--peach-dur-80) var(--peach-curve-arrival),10508+ background-color var(--peach-dur-80) var(--peach-curve-arrival);10509+}10510+.peach-sync-mode:hover:not(:disabled) {10511+ border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);10512+}10513+.peach-sync-mode.is-active {10514+ border-color: var(--peach-accent);10515+ background: color-mix(in srgb, var(--peach-accent) 8%, transparent);10516+}10517+.peach-sync-mode:disabled { opacity: 0.6; cursor: default; }10518+.peach-sync-mode-main { display: flex; flex-direction: column; gap: 3px; flex: 1; }10519+.peach-sync-mode-title { font-size: 12px; }10520+.peach-sync-mode-sub {10521+ font-size: 11px;10522+ line-height: 1.5;10523+ color: var(--peach-t2);10524+}10525+.peach-sync-mode-pip {10526+ flex: none;10527+ margin-top: 3px;10528+ width: 12px; height: 12px;10529+ border-radius: 50%;10530+ border: 1px solid var(--peach-t3);10531+}10532+.peach-sync-mode.is-active .peach-sync-mode-pip {10533+ border-color: var(--peach-accent);10534+ background:10535+ radial-gradient(circle at 50% 50%, var(--peach-accent) 0 42%, transparent 44%);10536+}src/popup/views/AdoptVaultView.test.tsx+1 −1
@@ -137,7 +137,7 @@137137 138138 expect(startAdoption).toHaveBeenCalledTimes(1);139139 expect(startAdoption.mock.calls[0][0]).toMatchObject({140- connection: { mode: "relay", invitation: INVITATION },140+ connection: { invitation: INVITATION },141141 });142142 });143143 src/popup/views/AdoptVaultView.tsx+41 −114
@@ -5,11 +5,9 @@55 createMemo,66 createSignal,77 onCleanup,8- onMount,98 } from "solid-js";109 import { sendMessage } from "../App";1110 import { RuneBackground } from "../components/RuneBackground";12-import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";1311 import { parsePairingInvitation } from "../../shared/pairing-invitation";1412 import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1513 @@ -20,9 +18,7 @@2018 onDone(): void | Promise<void>;2119 startAdoption(args: {2220 password: string;23- connection:24- | { mode: "relay"; invitation: string }25- | { mode: "lan"; code: string };21+ connection: { invitation: string };2622 confirmSAS(sas: string): Promise<boolean>;2723 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;2824 }): Promise<void>;@@ -38,7 +34,7 @@3834 case "invalid_code":3935 return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";4036 case "no_host_found":41- return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";37+ return "Couldn't reach the relay. Check your connection and that the other device's pairing screen is still open.";4238 case "handshake_failed":4339 return "The secure connection failed. Try again with a fresh code.";4440 case "adoption_rejected":@@ -73,9 +69,7 @@7369 const [sas, setSas] = createSignal("");7470 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);7571 const [error, setError] = createSignal("");76- const [code, setCode] = createSignal("");7772 const [invitation, setInvitation] = createSignal("");78- const [lanOnly, setLanOnly] = createSignal(false);7973 const [manualEntry, setManualEntry] = createSignal(false);8074 const [scanning, setScanning] = createSignal(false);8175 const [scanError, setScanError] = createSignal("");@@ -83,26 +77,11 @@8377 let videoEl: HTMLVideoElement | undefined;8478 let qrScanner: PairingQrScannerHandle | null = null;8579 86- const codeReady = createMemo(() => parsePairingCode(code()) !== null);8780 const invitationReady = createMemo(() =>8881 parsePairingInvitation(invitation()) !== null8982 );9083 9184 onCleanup(() => qrScanner?.stop());92- onMount(() => {93- void sendMessage({ type: "GET_RELAY_SETTINGS" })94- .then((response) => {95- const result = response as {96- ok: boolean;97- data?: { lanOnly?: boolean };98- };99- if (result.ok) setLanOnly(result.data?.lanOnly === true);100- })101- .catch(() => {102- // Fresh installs default to relay mode if the background is still103- // starting; the save path will reconcile before pairing completes.104- });105- });10685 10786 createMemo(() => {10887 const value = password();@@ -136,12 +115,8 @@136115 });137116 138117 async function beginPairing() {139- const connection = lanOnly()140- ? (codeReady() ? { mode: "lan" as const, code: code() } : null)141- : (invitationReady()142- ? { mode: "relay" as const, invitation: invitation() }143- : null);144- if (!connection || connecting()) return;118+ if (!invitationReady() || connecting()) return;119+ const connection = { invitation: invitation() };145120 stopScanning();146121 setPhase("pair");147122 setError("");@@ -185,7 +160,6 @@185160 setStrengthScore(null);186161 // The code is single-use on the other device's side too — a rejected SAS187162 // means starting over with a fresh one, not retrying this one.188- setCode("");189163 setInvitation("");190164 }191165 }@@ -225,17 +199,6 @@225199 setScanning(false);226200 }227201 228- async function chooseLanOnly(next: boolean) {229- stopScanning();230- setLanOnly(next);231- setManualEntry(false);232- const response = (await sendMessage({233- type: "SET_RELAY_SETTINGS",234- payload: { lanOnly: next },235- })) as { ok: boolean; error?: string };236- if (!response.ok) setScanError(response.error ?? "Could not update sync mode.");237- }238-239202 return (240203 <section class="peach-screen peach-theme peach-adopt-vault">241204 <RuneBackground opacity={0.07} />@@ -279,80 +242,44 @@279242 <span />280243 </div>281244 <h1>Pair with your other device.</h1>282- <Show283- when={!lanOnly()}284- fallback={285- <>286- <p>287- Local network only. Both devices must be on the same Wi-Fi;288- this will not work over VPN or from different locations.289- </p>290- <label class="peach-label" for="adopt-pair-code">local pairing code</label>291- <input292- id="adopt-pair-code"293- class="peach-underline-input peach-adopt-input peach-code-input"294- type="text"295- autocapitalize="characters"296- autocomplete="one-time-code"297- spellcheck={false}298- maxlength={32}299- placeholder="A74H-48J3-KI32-OEP4"300- aria-label="Pairing code"301- disabled={connecting()}302- value={code()}303- onInput={(event) =>304- setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))305- }306- onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}307- />308- <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(false)}>309- Use secure relay instead310- </button>311- </>312- }313- >314- <p>Connecting via secure relay — works anywhere.</p>315- <Show when={scanning()}>316- <video317- ref={videoEl}318- class="peach-adopt-qr-video"319- autoplay320- muted321- playsinline322- aria-label="Pairing QR camera preview"323- />324- </Show>325- <Show when={!scanning()}>326- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>327- Scan pairing QR328- </button>329- </Show>330- <button331- class="peach-btn peach-btn--ghost peach-btn--sm"332- onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}333- >334- {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}245+ <p>Connecting via secure relay — works anywhere.</p>246+ <Show when={scanning()}>247+ <video248+ ref={videoEl}249+ class="peach-adopt-qr-video"250+ autoplay251+ muted252+ playsinline253+ aria-label="Pairing QR camera preview"254+ />255+ </Show>256+ <Show when={!scanning()}>257+ <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>258+ Scan pairing QR335259 </button>336- <Show when={manualEntry()}>337- <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>338- <textarea339- id="adopt-relay-invitation"340- class="peach-underline-input peach-adopt-input"341- rows={3}342- autocomplete="off"343- spellcheck={false}344- aria-label="Pairing invitation"345- disabled={connecting()}346- value={invitation()}347- onInput={(event) =>348- setInvitation((event.target as HTMLTextAreaElement).value.trim())349- }350- />351- </Show>352- <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(true)}>353- Switch to local network only354- </button>355260 </Show>261+ <button262+ class="peach-btn peach-btn--ghost peach-btn--sm"263+ onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}264+ >265+ {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}266+ </button>267+ <Show when={manualEntry()}>268+ <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>269+ <textarea270+ id="adopt-relay-invitation"271+ class="peach-underline-input peach-adopt-input"272+ rows={3}273+ autocomplete="off"274+ spellcheck={false}275+ aria-label="Pairing invitation"276+ disabled={connecting()}277+ value={invitation()}278+ onInput={(event) =>279+ setInvitation((event.target as HTMLTextAreaElement).value.trim())280+ }281+ />282+ </Show>356283 <Show when={scanError()}>357284 <p class="peach-hint peach-hint-error">{scanError()}</p>358285 </Show>@@ -362,7 +289,7 @@362289 <div class="peach-adopt-actions">363290 <button364291 class="peach-btn peach-btn--accent peach-btn--md"365- disabled={(lanOnly() ? !codeReady() : !invitationReady()) || connecting()}292+ disabled={!invitationReady() || connecting()}366293 onClick={() => void beginPairing()}367294 >368295 Connect securelysrc/popup/views/PairDevice.test.tsx+5 −255
@@ -1,16 +1,13 @@11 // @vitest-environment jsdom22 //3-// Code pairing, exercised through the integrated PairDeviceView state machine.4-// Code pairing is a MODE inside PairDevice (birds always animate), not a separate5-// component — so these specs walk enter → connecting → SAS → paired/error,6-// asserting the popup↔background message contract at each step.3+// PairDeviceView is relay-only: the extension always DISPLAYS an invitation for4+// another device to scan. LAN pairing codes went with LAN sync5+// (docs/sync-lan-retirement.md), so there is no code-entry mode to test.76 //8-// The input accepts the address-bearing pairing code a phone displays.9-//107 // chrome messaging is mocked the way the popup uses it:118 // - shared/bridge `sendMessage` is mocked (popup → bg)129 // - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can13-// fire CODE_PAIR_SAS / CODE_PAIR_RESULT pushes (bg → popup)10+// fire ADOPTION_SOURCE_* pushes (bg → popup)1411 1512 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1613 @@ -18,14 +15,10 @@1815 const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));1916 vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));2017 21-// Mutated per-test to script the PAIR_WITH_CODE start response.22-let startResponse: { ok: boolean; error?: string } = { ok: true };23-let lanOnly = true;2418 const SOURCE_INVITATION =2519 "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2620 2721 import { PairDeviceView } from "./PairDevice";28-import type { PairedDevice } from "../../shared/sync-types";2922 import { renderView, inputText } from "./view-test-utils";3023 3124 // --- fake chrome.runtime push channel (bg → popup) ------------------------@@ -38,23 +31,8 @@3831 3932 beforeEach(() => {4033 listeners = [];41- startResponse = { ok: true };42- lanOnly = true;4334 mockSendMessage.mockReset();4435 mockSendMessage.mockImplementation((msg: { type?: string }) => {45- if (msg?.type === "GET_RELAY_SETTINGS") {46- // Resolve synchronously so the existing code-pairing state-machine tests47- // enter the explicitly configured LAN-only path before their first DOM48- // assertion.49- return {50- then(resolve: (value: unknown) => unknown) {51- return Promise.resolve(resolve({52- ok: true,53- data: { lanOnly },54- }));55- },56- };57- }5836 if (msg?.type === "START_ADOPTION_SOURCE") {5937 return Promise.resolve({6038 ok: true,@@ -64,7 +42,6 @@6442 if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {6543 return Promise.resolve({ ok: true, data: { valid: true } });6644 }67- if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse);6845 return Promise.resolve({ ok: true });6946 });7047 (globalThis as unknown as { chrome: unknown }).chrome = {@@ -84,11 +61,9 @@8461 });8562 8663 function mount() {87- lanOnly = false;8864 const rendered = renderView(8965 <PairDeviceView9066 isPro={true}91- initialPair="code"9267 onBack={() => {}}9368 onLearnPro={() => {}}9469 />,@@ -111,7 +86,7 @@11186 // Copy button duplicated the pairing-code path and were removed.11287 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();11388 expect(container.textContent).not.toContain("or copy the invitation");114- expect(container.textContent).toContain("single-use random token");89+ expect(container.textContent).toContain("single-use token");11590 });11691 11792 it("requires the matching SAS and master password before sharing", async () => {@@ -144,230 +119,5 @@144119 await vi.waitFor(() => {145120 expect(container.textContent).toContain("Waiting for the new device");146121 });147- });148-});149-150-describe("PairDevice — code pairing", () => {151- const VALID_CODE = "A74H-48J3-KI32-OEP4";152- const cleanups: Array<() => void> = [];153- afterEach(() => {154- while (cleanups.length) cleanups.pop()!();155- });156-157- // PairDevice is pairing-only; it opens straight into the code-entry flow158- // (the device list / method choice live on the Settings sync card).159- function mount(onBack: () => void = () => {}) {160- const r = renderView(161- <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,162- );163- cleanups.push(r.dispose);164- return r;165- }166-167- function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {168- return Array.from(container.querySelectorAll("button")).find(169- (b) => (b.textContent ?? "").trim() === text,170- ) as HTMLButtonElement | undefined;171- }172- function codeInput(container: HTMLElement): HTMLInputElement {173- return container.querySelector("#pair-code") as HTMLInputElement;174- }175-176- // initialPair="code" lands directly on the code-entry screen.177- function gotoCodeEntry(_container: HTMLElement): void {178- /* no-op: mount() already opens the code-entry flow */179- }180-181- it("opens straight into the code-entry flow (no method-select screen)", () => {182- const { container } = mount();183- expect(container.textContent).not.toContain("How do you want to pair?");184- // The code input is present immediately, without any intermediate choice.185- expect(codeInput(container)).toBeTruthy();186- expect(buttonByText(container, "Connect")).toBeTruthy();187- });188-189- it("renders a code input sized for a full pairing code, with birds present", () => {190- const { container } = mount();191- gotoCodeEntry(container);192- const input = codeInput(container);193- expect(input).toBeTruthy();194- // Letters are part of the code now, so the field must not be numeric-only.195- expect(input.getAttribute("inputmode")).toBeNull();196- expect(input.getAttribute("maxlength")).toBe("32");197- // birds always animate on the code screens (design correction)198- expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();199- });200-201- it("uppercases and regroups a pairing code as it is typed", () => {202- const { container } = mount();203- gotoCodeEntry(container);204- const input = codeInput(container);205- inputText(input, "a74h48j3ki32oep4");206- expect(input.value).toBe("A74H-48J3-KI32-OEP4");207- expect(buttonByText(container, "Connect")!.disabled).toBe(false);208- });209-210- it("groups partial input and strips unusable characters", () => {211- const { container } = mount();212- gotoCodeEntry(container);213- const input = codeInput(container);214- inputText(input, "12_34/56");215- expect(input.value).toBe("1234-56");216- expect(buttonByText(container, "Connect")!.disabled).toBe(true);217- });218-219- it("keeps Connect disabled for input that is neither code form", () => {220- const { container } = mount();221- gotoCodeEntry(container);222- // Long enough to be grouped, but too short to carry a token.223- inputText(codeInput(container), "A74H48J3");224- expect(buttonByText(container, "Connect")!.disabled).toBe(true);225- });226-227- it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {228- const { container } = mount();229- gotoCodeEntry(container);230- inputText(codeInput(container), VALID_CODE);231- const connect = buttonByText(container, "Connect")!;232- expect(connect.disabled).toBe(false);233- connect.click();234- await Promise.resolve();235- expect(mockSendMessage).toHaveBeenCalledWith({236- type: "PAIR_WITH_CODE",237- payload: { code: VALID_CODE },238- });239- expect(container.textContent).toContain("Connecting…");240- });241-242- it("Connect stays disabled until the full pairing code is entered", () => {243- const { container } = mount();244- gotoCodeEntry(container);245- inputText(codeInput(container), "A74H48J3");246- expect(buttonByText(container, "Connect")!.disabled).toBe(true);247- inputText(codeInput(container), VALID_CODE);248- expect(buttonByText(container, "Connect")!.disabled).toBe(false);249- });250-251- it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {252- const { container } = mount();253- gotoCodeEntry(container);254- inputText(codeInput(container), VALID_CODE);255- buttonByText(container, "Connect")!.click();256- await Promise.resolve();257-258- pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });259-260- expect(container.textContent).toContain("Confirm this code matches on your phone:");261- expect(container.textContent).toContain("89524517");262- expect(buttonByText(container, "Confirm")).toBeTruthy();263- expect(buttonByText(container, "Cancel")).toBeTruthy();264- });265-266- it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {267- const { container } = mount();268- gotoCodeEntry(container);269- inputText(codeInput(container), VALID_CODE);270- buttonByText(container, "Connect")!.click();271- await Promise.resolve();272- pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });273-274- buttonByText(container, "Confirm")!.click();275- await Promise.resolve();276- expect(mockSendMessage).toHaveBeenCalledWith({277- type: "CODE_PAIR_CONFIRM",278- payload: { confirmed: true },279- });280- });281-282- it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {283- const { container } = mount();284- gotoCodeEntry(container);285- inputText(codeInput(container), VALID_CODE);286- buttonByText(container, "Connect")!.click();287- await Promise.resolve();288- pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });289-290- buttonByText(container, "Cancel")!.click();291- await Promise.resolve();292- expect(mockSendMessage).toHaveBeenCalledWith({293- type: "CODE_PAIR_CONFIRM",294- payload: { confirmed: false },295- });296- expect(container.textContent).toContain("Codes don't match");297- });298-299- it("shows the combined 'not found or expired' copy on invalid_code", async () => {300- const { container } = mount();301- gotoCodeEntry(container);302- inputText(codeInput(container), VALID_CODE);303- buttonByText(container, "Connect")!.click();304- await Promise.resolve();305-306- // Wrong vs expired is indistinguishable on the wire; the background307- // collapses both to invalid_code via a socket close.308- pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });309-310- expect(container.textContent).toContain("Code not found or expired");311- expect(buttonByText(container, "Try again")).toBeTruthy();312- });313-314- it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {315- const { container } = mount();316- gotoCodeEntry(container);317- inputText(codeInput(container), VALID_CODE);318- buttonByText(container, "Connect")!.click();319- await Promise.resolve();320- pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });321- expect(container.textContent).toContain("Couldn't reach your phone");322- });323-324- it("renders Paired! and the device name on a complete result", async () => {325- const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };326- const { container } = mount();327- gotoCodeEntry(container);328- inputText(codeInput(container), VALID_CODE);329- buttonByText(container, "Connect")!.click();330- await Promise.resolve();331- pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });332- buttonByText(container, "Confirm")!.click();333- await Promise.resolve();334-335- pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });336- expect(container.textContent).toContain("Paired!");337- expect(container.textContent).toContain("Connected to June's Pixel");338- });339-340- it("auto-dismisses back to settings after the paired state settles", async () => {341- vi.useFakeTimers();342- try {343- const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };344- const onBack = vi.fn();345- const { container } = mount(onBack);346- gotoCodeEntry(container);347- inputText(codeInput(container), VALID_CODE);348- buttonByText(container, "Connect")!.click();349- await Promise.resolve();350- pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });351- buttonByText(container, "Confirm")!.click();352- await Promise.resolve();353- pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });354- expect(container.textContent).toContain("Paired!");355- vi.advanceTimersByTime(2000);356- // returns to the Settings sync hub, which owns the device list now357- expect(onBack).toHaveBeenCalled();358- } finally {359- vi.useRealTimers();360- }361- });362-363- it("surfaces a synchronous start failure (vault_locked) as an error", async () => {364- startResponse = { ok: false, error: "vault_locked" };365- const { container } = mount();366- gotoCodeEntry(container);367- inputText(codeInput(container), VALID_CODE);368- buttonByText(container, "Connect")!.click();369- await Promise.resolve();370- await Promise.resolve();371- expect(container.textContent).toContain("Unlock Peach first");372122 });373123 });src/popup/views/PairDevice.tsx+44 −311
@@ -2,28 +2,26 @@22 //33 // Settings owns the sync "hub" (paired-device list, revoke, sync-now); this44 // view is purely the pairing flow, returning to Settings (`onBack`) when done5-// or cancelled: the user types the pairing code their phone displays, the6-// browser dials out to the address inside it, and the NK handshake + SAS run7-// from there (enter code → connecting → SAS → paired).5+// or cancelled.86 //9-// There is one path because there can only be one: the browser cannot listen,10-// so it is always the side that dials.7+// There is one path because there can only be one: a browser extension cannot8+// listen and has no camera it can point at anything, so it is always the side9+// that *displays* an invitation for another device to scan. LAN pairing codes10+// are retired along with LAN sync (docs/sync-lan-retirement.md), so there is no11+// code to type here either.1112 //12-// The pairing itself runs in the background (code-pairing.ts) and reaches13-// this view via the PAIR_WITH_CODE / CODE_PAIR_* messages.13+// The pairing itself runs in the background (relay-adoption-source-bg.ts) and14+// reaches this view via the ADOPTION_SOURCE_* messages.1415 1516 import {1617 Component,1718 Show,18- createEffect,1919 createSignal,2020 onCleanup,2121 onMount,2222 } from "solid-js";2323 import { sendMessage } from "../../shared/bridge";24-import type { PairedDevice } from "../../shared/sync-types";2524 import { DesktopBird, MobileBird } from "../components/PairingBirds";26-import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2725 import { AdoptionConsentModal } from "../components/AdoptionConsentModal";2826 import { PairingQrCode } from "../components/PairingQrCode";2927 @@ -31,93 +29,20 @@3129 isPro: boolean;3230 onBack(): void;3331 onLearnPro(): void;34- /**35- * Set when the user taps the pairing option on the Settings sync card. With36- * no method there is nothing to do here, so the view returns to Settings.37- * Read once on mount.38- */39- initialPair?: "code" | null;4032 /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */4133 onProChanged?: () => void;4234 }4335 44-// Code-pairing phases. Mirrors the background CodePairingError union.45-type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";4636 type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";47-type CodePairingError =48- | "invalid_code"49- | "no_host_found"50- | "handshake_failed"51- | "sas_mismatch"52- | "vault_locked";5337 54-const PAIRED_DISMISS_MS = 2_000;55-56-function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {57- switch (error) {58- case "invalid_code":59- // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —60- // the listener closes the socket either way, with no error frame.61- return {62- headline: "Code not found or expired",63- sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",64- };65- case "no_host_found":66- return {67- headline: "Couldn't reach your phone",68- sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",69- };70- case "sas_mismatch":71- return {72- headline: "Codes don't match",73- sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",74- };75- case "vault_locked":76- return {77- headline: "Unlock Peach first",78- sub: "Your vault needs to be unlocked before you can pair a device.",79- };80- case "handshake_failed":81- default:82- return {83- headline: "Pairing failed",84- sub: "Something went wrong while connecting. Please try again.",85- };86- }87-}88-8938 export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {90- // The whole NK pairing runs in the background; this view drives the UI and91- // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /92- // CODE_PAIR_* messages. The SAS gate is what authenticates the peer93- // (audit HIGH — pairing initiator authentication), not the pairing token.94- const [phase, setPhase] = createSignal<PairPhase>("enter");95- const [code, setCode] = createSignal("");96- const [sas, setSas] = createSignal<string | null>(null);97- const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");98- const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);99- const [busy, setBusy] = createSignal(false);100- const [mode, setMode] = createSignal<"share" | "connect">("share");10139 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");10240 const [invitation, setInvitation] = createSignal("");10341 const [sourceSas, setSourceSas] = createSignal("");10442 const [receiverName, setReceiverName] = createSignal("New device");10543 const [shareError, setShareError] = createSignal("");10644 107- const codeReady = () => parsePairingCode(code()) !== null;108-109- function startFlow(): void {110- setMode("connect");111- setCode("");112- setSas(null);113- setErrorKind("unknown");114- setPairedDevice(undefined);115- setBusy(false);116- setPhase("enter");117- }118-11945 async function startShareFlow(): Promise<void> {120- setMode("share");12146 setSharePhase("opening");12247 setShareError("");12348 setInvitation("");@@ -145,11 +70,6 @@14570 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);14671 }14772 148- async function useCodeInstead(): Promise<void> {149- await stopShareFlow();150- startFlow();151- }152-15373 async function verifySourcePassword(password: string): Promise<boolean> {15474 const response = (await sendMessage({15575 type: "ADOPTION_SOURCE_CONFIRM",@@ -158,90 +78,15 @@15878 return response.ok && response.data?.valid === true;15979 }16080 161- function onCodeInput(value: string): void {162- const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);163- setCode(formatPairingCode(cleaned));164- }165-166- async function handleConnect(): Promise<void> {167- if (!codeReady() || busy()) return;168- setBusy(true);169- setPhase("connecting");170- try {171- const res = (await sendMessage({172- type: "PAIR_WITH_CODE",173- payload: { code: code() },174- })) as { ok: boolean; error?: string };175- // A synchronous start failure (e.g. vault_locked) returns here; the SAS176- // and the terminal result arrive later as pushes.177- if (!res.ok) {178- setErrorKind((res.error as CodePairingError) ?? "unknown");179- setPhase("error");180- }181- } catch {182- setErrorKind("unknown");183- setPhase("error");184- } finally {185- setBusy(false);186- }187- }188-189- async function respondConfirm(confirmed: boolean): Promise<void> {190- if (busy()) return;191- setBusy(true);192- // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the193- // final phase (paired vs sas_mismatch error).194- if (confirmed) setPhase("connecting");195- else { setErrorKind("sas_mismatch"); setPhase("error"); }196- try {197- await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });198- } finally {199- setBusy(false);200- }201- }202-203- // Auto-dismiss after the paired state settles — back to the Settings hub,204- // which reloads the paired-device list on mount.205- createEffect(() => {206- if (phase() !== "paired") return;207- const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);208- onCleanup(() => clearTimeout(t));209- });210-21181 onMount(() => {212- // Opened straight into a pairing path from the Settings sync card. With no213- // method there's nothing to do here — return to Settings (the hub).214- if (props.initialPair === "code") {215- void sendMessage({ type: "GET_RELAY_SETTINGS" })216- .then((response) => {217- const settings = response as {218- ok: boolean;219- data?: { lanOnly?: boolean };220- };221- if (settings.ok && settings.data?.lanOnly !== true) {222- return startShareFlow();223- }224- startFlow();225- })226- .catch(() => startFlow());227- } else props.onBack();82+ void startShareFlow();22883 229- // Pairing pushes from the background (bg → popup). The whole NK230- // pairing runs in the service worker; the SAS to confirm and the terminal231- // result arrive here as runtime messages.84+ // Pairing pushes from the background (bg → popup). The whole NK session85+ // runs in the service worker; the SAS to confirm and the terminal result86+ // arrive here as runtime messages.23287 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {23388 const handler = (msg: { type?: string; payload?: unknown }) => {234- if (msg?.type === "CODE_PAIR_SAS") {235- const p = msg.payload as { sas?: string } | undefined;236- if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }237- } else if (msg?.type === "CODE_PAIR_RESULT") {238- const p = msg.payload as239- | { status: "complete"; device: PairedDevice }240- | { status: "error"; error: CodePairingError }241- | undefined;242- if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }243- else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }244- } else if (msg?.type === "ADOPTION_SOURCE_SAS") {89+ if (msg?.type === "ADOPTION_SOURCE_SAS") {24590 const p = msg.payload as {24691 sas?: string;24792 receiverDeviceName?: string;@@ -270,21 +115,16 @@270115 chrome.runtime.onMessage.addListener(handler);271116 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));272117 }273- // If the popup closes while the user is on the SAS screen, resolve the274- // pending background confirm as false so the service worker clears275- // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".118+119+ // Closing the popup mid-session must release the relay room, or the next120+ // attempt is refused with adoption_source_in_progress.276121 onCleanup(() => {277- if (phase() === "sas") {278- void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });279- }280- if (mode() === "share" && sharePhase() !== "paired") {122+ if (sharePhase() !== "paired") {281123 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });282124 }283125 });284126 });285127 286- // --- Sub-views --------------------------------------------------287-288128 const backArrow = (289129 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">290130 <path d="M10 3 L5 8 L10 13" />@@ -303,127 +143,34 @@303143 </div>304144 );305145 146+ function errorCopy(): string {147+ switch (shareError()) {148+ case "vault_locked":149+ return "Unlock Peach first, then try again.";150+ case "sync_disabled":151+ return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";152+ default:153+ return "The secure pairing session ended. Start again with a fresh code.";154+ }155+ }156+306157 function liveText(): string {307- switch (phase()) {308- case "enter": return "Enter the pairing code from your phone";309- case "connecting": return "Connecting to your phone";310- case "sas": return "Confirm the code matches on your phone";311- case "paired": return "Paired";312- case "error": return errorCopy(errorKind()).headline;158+ switch (sharePhase()) {159+ case "opening": return "Opening a secure pairing room";160+ case "invitation": return "Scan this code on your other device";161+ case "sas": return "Confirm the code matches";162+ case "connecting": return "Waiting for the new device";163+ case "paired": return "Device added";164+ case "error": return "Could not add the device";313165 }314166 }315167 316- // Code pairing — birds animate on every screen below.317- const pairingScreen = () => (318- <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">319- <header class="peach-pair-head">320- <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>321- <div class="peach-pair-title">Enter a code</div>322- <div class="peach-pair-head-spacer" />323- </header>324-325- {birdStage(phase() !== "enter")}326-327- <Show when={phase() === "enter"}>328- <div class="peach-desktop-enter">329- <div class="peach-pair-headline">Enter the code from your phone</div>330- <div class="peach-pair-sub">331- Open Peach on your phone, tap “Pair a device”, and type the code it shows.332- Both devices need to be on the same Wi-Fi.333- </div>334- <label class="peach-label" for="pair-code">pairing code</label>335- <input336- id="pair-code"337- class="peach-underline-input peach-unlock-input peach-code-input"338- type="text"339- autocapitalize="characters"340- autocomplete="one-time-code"341- spellcheck={false}342- maxlength={32}343- placeholder="A74H-48J3-KI32-OEP4"344- aria-label="Pairing code"345- value={code()}346- ref={(el) => setTimeout(() => el.focus(), 80)}347- onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}348- onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}349- />350- <div class="peach-pair-action">351- <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>352- <button353- class="peach-btn peach-btn-primary peach-desktop-connect"354- disabled={!codeReady() || busy()}355- onClick={() => void handleConnect()}356- >357- Connect358- </button>359- </div>360- </div>361- </Show>362-363- <Show when={phase() === "connecting"}>364- <div class="peach-desktop-searching">365- <div class="peach-pair-headline">Connecting…</div>366- <div class="peach-pair-sub">Reaching your phone over the local network.</div>367- </div>368- </Show>369-370- <Show when={phase() === "sas"}>371- <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">372- <div class="peach-pair-headline">Confirm this code matches on your phone:</div>373- <div374- class="peach-desktop-sas"375- style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"376- >377- {sas()}378- </div>379- <div class="peach-pair-sub" style="max-width:240px;">380- Only continue if this 8-digit code is identical on both screens. This is381- what proves you're talking to your own device.382- </div>383- <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">384- <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>385- Confirm386- </button>387- <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>388- Cancel389- </button>390- </div>391- </div>392- </Show>393-394- <Show when={phase() === "paired"}>395- <div class="peach-desktop-paired">396- <div class="peach-pair-headline">Paired!</div>397- <div class="peach-pair-sub">398- {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}399- </div>400- <div class="peach-pair-action">401- <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>402- </div>403- </div>404- </Show>405-406- <Show when={phase() === "error"}>407- <div class="peach-desktop-error">408- <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>409- <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>410- <div class="peach-pair-action">411- <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412- <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>413- </div>414- </div>415- </Show>416-417- <div class="peach-sr-only" aria-live="polite">{liveText()}</div>418- </div>419- );420-421- const shareScreen = () => (168+ return (422169 <div423170 class="peach-pair-pair peach-desktop-pair peach-adoption-source"424171 data-phase={sharePhase()}425172 role="dialog"426- aria-label="Share this vault with a new device"173+ aria-label="Add a device to this vault"427174 >428175 <header class="peach-pair-head">429176 <button@@ -442,7 +189,7 @@442189 <Show when={sharePhase() === "opening"}>443190 <div class="peach-desktop-searching">444191 <div class="peach-pair-headline">Opening a secure pairing room…</div>445- <div class="peach-pair-sub">The QR appears only after this browser is waiting in the relay.</div>192+ <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>446193 </div>447194 </Show>448195 @@ -450,18 +197,10 @@450197 <div class="peach-adoption-source-invitation">451198 <div class="peach-pair-headline">Scan this in Peach</div>452199 <div class="peach-pair-sub">453- Connecting via secure relay — works anywhere. The QR contains only454- the relay address and a single-use random token.200+ Open Peach on your phone, choose “Scan a code”, and point it here.201+ The code carries only the relay address and a single-use token.455202 </div>456203 <PairingQrCode value={invitation()} size={188} />457- <div class="peach-pair-action">458- <button459- class="peach-btn peach-btn-ghost"460- onClick={() => void useCodeInstead()}461- >462- Enter a code instead463- </button>464- </div>465204 </div>466205 </Show>467206 @@ -475,7 +214,7 @@475214 <Show when={sharePhase() === "paired"}>476215 <div class="peach-desktop-paired">477216 <div class="peach-pair-headline">Device added</div>478- <div class="peach-pair-sub">The new device can now sync this vault through the secure relay.</div>217+ <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>479218 <div class="peach-pair-action">480219 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>481220 </div>@@ -485,11 +224,7 @@485224 <Show when={sharePhase() === "error"}>486225 <div class="peach-desktop-error">487226 <div class="peach-pair-headline">Could not add the device</div>488- <div class="peach-pair-sub">489- {shareError() === "vault_locked"490- ? "Unlock Peach first, then try again."491- : "The secure pairing session ended. Start again with a fresh QR."}492- </div>227+ <div class="peach-pair-sub">{errorCopy()}</div>493228 <div class="peach-pair-action">494229 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>495230 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>@@ -515,10 +250,8 @@515250 }}516251 />517252 </Show>253+254+ <div class="peach-sr-only" aria-live="polite">{liveText()}</div>518255 </div>519256 );520-521- // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can522- // receive Pro from an already-Pro peer. The upsell lives on the Settings card.523- return mode() === "share" ? shareScreen() : pairingScreen();524257 };src/popup/views/Settings.tsx+92 −59
@@ -270,7 +270,7 @@270270 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271271 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272272 const [relayUrl, setRelayUrl] = createSignal("");273- const [lanOnly, setLanOnly] = createSignal(false);273+ const [syncMode, setSyncMode] = createSignal<"public" | "private" | "off">("public");274274 const [relaySaving, setRelaySaving] = createSignal(false);275275 const [relayStatus, setRelayStatus] = createSignal<string | null>(null);276276 @@ -342,30 +342,31 @@342342 }343343 }344344 async function loadRelaySettings() {345- const result = (await sendMessage({ type: "GET_RELAY_SETTINGS" })) as {345+ const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {346346 ok: boolean;347- data?: { relayUrl: string; lanOnly: boolean };347+ data?: { relayUrl: string; mode: "public" | "private" | "off" };348348 };349349 if (result.ok && result.data) {350350 setRelayUrl(result.data.relayUrl);351- setLanOnly(result.data.lanOnly);351+ setSyncMode(result.data.mode);352352 }353353 }354- async function saveRelaySettings(nextLanOnly = lanOnly()) {354+ /** Pass a mode to switch; pass nothing to save the typed private relay URL. */355+ async function saveSyncSettings(mode?: "public" | "private" | "off") {355356 setRelaySaving(true);356357 setRelayStatus(null);357358 const result = (await sendMessage({358- type: "SET_RELAY_SETTINGS",359- payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly },359+ type: "SET_SYNC_SETTINGS",360+ payload: mode ? { mode } : { relayUrl: relayUrl() },360361 })) as {361362 ok: boolean;362363 error?: string;363- data?: { relayUrl: string; lanOnly: boolean };364+ data?: { relayUrl: string; mode: "public" | "private" | "off" };364365 };365366 if (result.ok && result.data) {366367 setRelayUrl(result.data.relayUrl);367- setLanOnly(result.data.lanOnly);368- setRelayStatus("Saved");368+ setSyncMode(result.data.mode);369+ setRelayStatus(mode ? null : "Saved");369370 } else {370371 setRelayStatus("Use a valid wss:// relay URL");371372 }@@ -1574,9 +1575,9 @@15741575 15751576 {/* Pick a pairing method and begin — no separate hub screen. */}15761577 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1577- {/* One path in: the phone listens and shows a code, the browser dials1578- it. A browser extension can never accept a connection, so there is1579- no flow where this device is the one being joined. */}1578+ {/* One path in: this browser displays an invitation and the other1579+ device scans it. An extension can neither listen nor use a camera,1580+ so displaying is the only role it can take. */}15801581 <button15811582 class="peach-sync-pair-option"15821583 onClick={() => props.onOpenSyncPairing?.("code")}@@ -1588,8 +1589,8 @@15881589 </svg>15891590 </span>15901591 <span class="peach-sync-pair-option-main">1591- <span class="peach-sync-pair-option-title">Phone or tablet</span>1592- <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1592+ <span class="peach-sync-pair-option-title">Show a code</span>1593+ <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>15931594 </span>15941595 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>15951596 </button>@@ -1599,54 +1600,86 @@15991600 </p>16001601 </Show>16011602 1602- <p class="peach-settings-sub-label" style="margin-top:12px">CONNECTION</p>1603- <div class="peach-settings-row peach-settings-row--top">1604- <div class="peach-settings-row-main">1605- <span>Use local network only (no relay)</span>1606- <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607- Sync will only work when your devices share the same local network.1608- It won't work over VPN or when your devices are in different locations.1603+ {/* Three named choices, not a toggle with an implied default.1604+ LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"1605+ now means no sync rather than a peer-to-peer mode that never1606+ reliably worked. */}1607+ <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1608+1609+ <button1610+ class="peach-sync-mode"1611+ classList={{ "is-active": syncMode() === "public" }}1612+ disabled={relaySaving()}1613+ onClick={() => void saveSyncSettings("public")}1614+ >1615+ <span class="peach-sync-mode-main">1616+ <span class="peach-sync-mode-title">Public relay</span>1617+ <span class="peach-sync-mode-sub">1618+ Peach's own relay. Works anywhere, nothing to set up. It never sees1619+ your vault — everything is encrypted before it leaves the device.16091620 </span>1610- </div>1611- <label class="peach-switch">1612- <input1613- type="checkbox"1614- checked={lanOnly()}1615- disabled={relaySaving()}1616- onChange={(event) => {1617- const enabled = (event.target as HTMLInputElement).checked;1618- setLanOnly(enabled);1619- void saveRelaySettings(enabled);1620- }}1621- />1622- <span class="peach-switch-track" />1623- </label>1624- </div>1625- <label class="peach-label" for="sync-relay-url">mesh relay URL</label>1626- <input1627- id="sync-relay-url"1628- class="peach-underline-input"1629- type="url"1630- inputmode="url"1631- autocomplete="off"1632- spellcheck={false}1633- value={relayUrl()}1621+ </span>1622+ <span class="peach-sync-mode-pip" aria-hidden="true" />1623+ </button>1624+1625+ <button1626+ class="peach-sync-mode"1627+ classList={{ "is-active": syncMode() === "private" }}16341628 disabled={relaySaving()}1635- onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1636- onKeyDown={(event) => {1637- if (event.key === "Enter") void saveRelaySettings();1638- }}1639- />1640- <div class="peach-settings-row peach-settings-row--meta">1641- <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1642- {relayStatus() ?? "Only secure wss:// relays are accepted."}1629+ onClick={() => void saveSyncSettings("private")}1630+ >1631+ <span class="peach-sync-mode-main">1632+ <span class="peach-sync-mode-title">Private relay</span>1633+ <span class="peach-sync-mode-sub">1634+ Your own peach-relay, on your own domain. Same open-source code.1635+ </span>16431636 </span>1644- <button1645- class="peach-btn peach-btn--subtle peach-btn--sm"1637+ <span class="peach-sync-mode-pip" aria-hidden="true" />1638+ </button>1639+1640+ <Show when={syncMode() === "private"}>1641+ <label class="peach-label" for="sync-relay-url">relay URL</label>1642+ <input1643+ id="sync-relay-url"1644+ class="peach-underline-input"1645+ type="url"1646+ inputmode="url"1647+ autocomplete="off"1648+ spellcheck={false}1649+ value={relayUrl()}16461650 disabled={relaySaving()}1647- onClick={() => void saveRelaySettings()}1648- >{relaySaving() ? "saving…" : "save"}</button>1649- </div>1651+ onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1652+ onKeyDown={(event) => {1653+ if (event.key === "Enter") void saveSyncSettings();1654+ }}1655+ />1656+ <div class="peach-settings-row peach-settings-row--meta">1657+ <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1658+ {relayStatus() ?? "Only secure wss:// relays are accepted."}1659+ </span>1660+ <button1661+ class="peach-btn peach-btn--subtle peach-btn--sm"1662+ disabled={relaySaving()}1663+ onClick={() => void saveSyncSettings()}1664+ >{relaySaving() ? "saving…" : "save"}</button>1665+ </div>1666+ </Show>1667+1668+ <button1669+ class="peach-sync-mode"1670+ classList={{ "is-active": syncMode() === "off" }}1671+ disabled={relaySaving()}1672+ onClick={() => void saveSyncSettings("off")}1673+ >1674+ <span class="peach-sync-mode-main">1675+ <span class="peach-sync-mode-title">No sync</span>1676+ <span class="peach-sync-mode-sub">1677+ This device keeps to itself. Move your vault by hand with Codex or1678+ CSV export and import.1679+ </span>1680+ </span>1681+ <span class="peach-sync-mode-pip" aria-hidden="true" />1682+ </button>16501683 16511684 {/* Paired devices + manual sync — Settings is the hub. */}16521685 <Show when={pairedDevices().length > 0}>src/shared/local-network.ts+0 −28
@@ -1,28 +0,0 @@1-// Local network discovery — minimal helper used by the legacy code-pairing probe.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 when a legacy addressless code has to be resolved. Legacy v2.147-// discovery/pair probes have been removed — see code-pairing.ts for the8-// v3 pairing-port scan and sync-transport.ts for the rate-limited9-// Phase-2 subnet probe.10-11-// Returns e.g. "192.168.1" or null if unavailable.12-export async function getLocalSubnet(): Promise<string | null> {13- return new Promise((resolve) => {14- try {15- const pc = new RTCPeerConnection({ iceServers: [] });16- pc.createDataChannel('');17- pc.onicecandidate = (e) => {18- if (!e.candidate) { pc.close(); resolve(null); return; }19- const m = e.candidate.candidate.match(/(\d+\.\d+\.\d+)\.\d+/);20- if (m) { pc.close(); resolve(m[1]); }21- };22- pc.createOffer().then(o => pc.setLocalDescription(o)).catch(() => { pc.close(); resolve(null); });23- setTimeout(() => { try { pc.close(); } catch { /* ignore */ } resolve(null); }, 2000);24- } catch {25- resolve(null);26- }27- });28-}src/shared/pairing-code.test.ts+0 −100
@@ -1,100 +0,0 @@1-import { describe, it, expect } from "vitest";2-import {3- PAIRING_TOKEN_BYTES,4- encodePairingCode,5- formatPairingCode,6- parsePairingCode,7-} from "./pairing-code";8-9-const token4 = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);10-11-describe("pairing-code", () => {12- // GOLDEN VECTOR — Android's PairingCode.kt is pinned against this exact13- // output. Changing it is a coordinated cross-repo change, never a local edit.14- it("golden vector: 192.168.1.42:49142 + deadbeef -> R2M0-2ANZ-YVFA-VFQF", () => {15- const code = encodePairingCode("192.168.1.42", 49142, token4);16- expect(code).toBe("R2M0-2ANZ-YVFA-VFQF");17- expect(parsePairingCode(code)).toEqual({18- ip: "192.168.1.42",19- port: 49142,20- tokenHex: "deadbeef",21- });22- });23-24- it("is exactly 16 characters in 4 groups — the whole point of a 4-byte token", () => {25- const code = encodePairingCode("192.168.1.42", 49142, token4);26- expect(code.replace(/-/g, "")).toHaveLength(16);27- expect(code).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);28- expect(PAIRING_TOKEN_BYTES).toBe(4);29- });30-31- it("refuses to encode a token of any other size", () => {32- // An 8-byte token is what produced the 23-character, 6-group codes; Base3233- // gives 5 bits per character, so there is no encoding fix — only this gate.34- for (const n of [3, 5, 6, 8, 10]) {35- expect(() => encodePairingCode("192.168.1.42", 49142, new Uint8Array(n))).toThrow(/exactly 4 bytes/);36- }37- });38-39- it("still decodes an older 8-byte-token code so a stale peer can pair", () => {40- // Real code captured from an Android emulator build before the fix.41- expect(parsePairingCode("1800-43XF-HPH0-2EP9-A3PR-7P0")).toEqual({42- ip: "10.0.2.15",43- port: 44941,44- tokenHex: "a2013ac950ed83d8",45- });46- });47-48- it("preserves the full port range", () => {49- for (const port of [1, 49142, 51555, 65535]) {50- const code = encodePairingCode("192.168.4.230", port, token4);51- expect(parsePairingCode(code)?.port).toBe(port);52- }53- });54-55- it("folds the ambiguous glyphs I, L and O on the way in", () => {56- const code = encodePairingCode("192.168.1.42", 49142, token4);57- const ambiguous = code.replace(/0/g, "O").replace(/1/g, "I");58- expect(parsePairingCode(ambiguous)).toEqual(parsePairingCode(code));59- });60-61- it("accepts a code typed without dashes, in lowercase, with stray spaces", () => {62- const code = encodePairingCode("192.168.1.42", 49142, token4);63- const sloppy = ` ${code.replace(/-/g, " ").toLowerCase()} `;64- expect(parsePairingCode(sloppy)).toEqual(parsePairingCode(code));65- });66-67- it("rejects codes that are too short to carry a token", () => {68- // 4 IP bytes + 2 port bytes and nothing else — 10 base32 characters.69- expect(parsePairingCode("ABCDEFGHJK")).toBeNull();70- expect(parsePairingCode("")).toBeNull();71- expect(parsePairingCode("---")).toBeNull();72- });73-74- it("rejects characters outside Crockford's alphabet", () => {75- // U is excluded by Crockford and is NOT folded, unlike I/L/O.76- expect(parsePairingCode("UUUU-UUUU-UUUU-UUUU")).toBeNull();77- expect(parsePairingCode("A74H-48J3-KI32-OEP!")).toBeNull();78- });79-80- it("rejects a zero port", () => {81- // An all-zero payload is long enough but decodes to 0.0.0.0:0 — a code82- // that parsed would send the dialler at an unroutable endpoint.83- expect(parsePairingCode("0000-0000-0000-0000")).toBeNull();84- });85-86- it("refuses to encode an out-of-range address, port, or short token", () => {87- expect(() => encodePairingCode("192.168.1.256", 49142, token4)).toThrow();88- expect(() => encodePairingCode("192.168.1", 49142, token4)).toThrow();89- expect(() => encodePairingCode("192.168.1.42", 0, token4)).toThrow();90- expect(() => encodePairingCode("192.168.1.42", 70000, token4)).toThrow();91- expect(() => encodePairingCode("192.168.1.42", 49142, Uint8Array.of(1, 2, 3))).toThrow();92- });93-94- it("regroups partial input as the user types", () => {95- expect(formatPairingCode("a74h48")).toBe("A74H-48");96- expect(formatPairingCode("A74H-48J3-KI32-OEP4")).toBe("A74H-48J3-KI32-OEP4");97- expect(formatPairingCode("a74h 48j3")).toBe("A74H-48J3");98- expect(formatPairingCode("")).toBe("");99- });100-});src/shared/pairing-code.ts+0 −168
@@ -1,168 +0,0 @@1-// Pairing code — the compact join code the phone displays and the user types2-// into the browser.3-//4-// Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),5-// encoded in Crockford's Base32 and displayed in groups of four:6-//7-// A74H-48J3-KI32-OEP48-//9-// The browser only ever DECODES. The phone is the listener and the only side10-// that generates codes; `encodePairingCode` exists so the wire format has one11-// authoritative definition and so the round trip is testable.12-//13-// Crockford's alphabet omits I, L, O and U. Decoding still folds I/L -> 1 and14-// O -> 0, so a code misread off a phone screen (or a doc example that used the15-// ambiguous glyphs) still resolves to the right bytes.16-//17-// LENGTH — this is the whole reason the token is 4 bytes and not 8.18-//19-// Base32 carries 5 bits per character, so the code length is fixed arithmetic,20-// not something an encoder can optimise:21-//22-// token 4B -> 10B payload -> 80 bits -> 16 chars -> 4 groups <- target23-// token 6B -> 12B payload -> 96 bits -> 20 chars -> 5 groups24-// token 8B -> 14B payload -> 112 bits -> 23 chars -> 6 groups25-//26-// The original spec asked for BOTH an 8–10 byte token AND ~16 characters. Those27-// are not simultaneously satisfiable: 16 characters is exactly 80 bits, and28-// IP (32) + port (16) leaves exactly 32 bits. A 4-byte token is not a29-// compromise, it is the only token size that fits four groups. If you widen the30-// token, the code gets longer — there is no encoding trick that avoids it.31-//32-// SECURITY: 32 bits is sufficient here and the entropy is not what protects33-// pairing. The token travels in the clear to the phone (see the hello frame in34-// pairing-dial.ts) and is NOT the security boundary — the user-compared SAS35-// after the Noise NK handshake is. The token's job is to stop an unrelated host36-// on the same LAN from consuming the phone's pairing window during a 5–1037-// minute, single-use window. That does assume the LISTENER closes on a wrong38-// token rather than allowing unbounded guessing — which it does today (a wrong39-// token gets a silent close and the code rotates).40-41-const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";42-const IP_BYTES = 4;43-const PORT_BYTES = 2;44-45-/**46- * The canonical token size. Encoding anything else is refused so both sides47- * keep deriving from one format — this is the value Android's golden vector is48- * pinned against.49- */50-export const PAIRING_TOKEN_BYTES = 4;51-52-// Decoding stays deliberately lenient about token length so a peer on an older53-// build (8-byte tokens, 23 characters) still pairs instead of hard-failing.54-const MIN_TOKEN_BYTES = 4;55-const MAX_TOKEN_BYTES = 16;56-57-export interface PairingCode {58- ip: string;59- port: number;60- /** The one-time token, hex-encoded — presented verbatim in the hello frame. */61- tokenHex: string;62-}63-64-/**65- * Decode a typed pairing code. Returns null for anything that is not a66- * well-formed code — the caller treats null as "not a pairing code" rather67- * than as an error, so other code formats can share the same input field.68- */69-export function parsePairingCode(input: string): PairingCode | null {70- const bytes = decodeCrockford(input);71- if (!bytes) return null;72- const tokenBytes = bytes.length - IP_BYTES - PORT_BYTES;73- if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null;74- const port = (bytes[4] << 8) | bytes[5];75- if (port === 0) return null;76- return {77- ip: `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`,78- port,79- tokenHex: toHex(bytes.subarray(IP_BYTES + PORT_BYTES)),80- };81-}82-83-/**84- * Build a pairing code. Throws on an unencodable address or port, or on a token85- * that is not exactly `PAIRING_TOKEN_BYTES` — a longer token silently produces86- * a longer code, which is the bug this strictness exists to prevent.87- */88-export function encodePairingCode(ip: string, port: number, token: Uint8Array): string {89- const octets = ip.split(".");90- if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4");91- const bytes = new Uint8Array(IP_BYTES + PORT_BYTES + token.length);92- for (let i = 0; i < IP_BYTES; i++) {93- const n = Number(octets[i]);94- if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid IPv4 octet: ${octets[i]}`);95- bytes[i] = n;96- }97- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`);98- bytes[4] = (port >> 8) & 0xff;99- bytes[5] = port & 0xff;100- if (token.length !== PAIRING_TOKEN_BYTES) {101- throw new Error(102- `token must be exactly ${PAIRING_TOKEN_BYTES} bytes (got ${token.length}); ` +103- "every extra byte adds 1.6 characters to the code",104- );105- }106- bytes.set(token, IP_BYTES + PORT_BYTES);107- return group(encodeCrockford(bytes));108-}109-110-/**111- * Normalise partial user input for display in the code field: drop the112- * characters Crockford has no place for, uppercase, and re-group in fours.113- * Applied on every keystroke, so it must tolerate an incomplete code.114- */115-export function formatPairingCode(input: string): string {116- return group(input.toUpperCase().replace(/[^0-9A-Z]/g, ""));117-}118-119-function group(chars: string): string {120- return chars.replace(/(.{4})(?=.)/g, "$1-");121-}122-123-function decodeCrockford(input: string): Uint8Array | null {124- const clean = input125- .toUpperCase()126- .replace(/[\s-]/g, "")127- .replace(/[ILO]/g, (c) => (c === "O" ? "0" : "1"));128- if (clean.length === 0) return null;129-130- // Trailing bits that don't complete a byte are padding and are dropped.131- const out = new Uint8Array(Math.floor((clean.length * 5) / 8));132- let acc = 0;133- let bits = 0;134- let o = 0;135- for (const ch of clean) {136- const v = ALPHABET.indexOf(ch);137- if (v < 0) return null;138- acc = (acc << 5) | v;139- bits += 5;140- if (bits >= 8) {141- bits -= 8;142- out[o++] = (acc >> bits) & 0xff;143- }144- }145- return out;146-}147-148-function encodeCrockford(bytes: Uint8Array): string {149- let s = "";150- let acc = 0;151- let bits = 0;152- for (const b of bytes) {153- acc = (acc << 8) | b;154- bits += 8;155- while (bits >= 5) {156- bits -= 5;157- s += ALPHABET[(acc >> bits) & 0x1f];158- }159- }160- if (bits > 0) s += ALPHABET[(acc << (5 - bits)) & 0x1f];161- return s;162-}163-164-function toHex(bytes: Uint8Array): string {165- let s = "";166- for (const b of bytes) s += b.toString(16).padStart(2, "0");167- return s;168-}src/shared/pairing-dial.test.ts+0 −193
@@ -1,193 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-import {4- dialPairingChannel,5- dialRelayPairingChannel,6- type PairingSocket,7-} from "./pairing-dial";8-import { encodePairingCode } from "./pairing-code";9-import { createPairingInvitation } from "./pairing-invitation";10-11-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));12-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));13-14-const LISTENER_EPHEMERAL_HEX = "a".repeat(64);15-const CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(0xde, 0xad, 0xbe, 0xef));16-const RELAY_TOKEN = "AAECAwQFBgcICQoLDA0ODw";17-const RELAY_INVITATION = createPairingInvitation(18- "wss://relay.example.test/base",19- RELAY_TOKEN,20-);21-22-const FAKE_HANDSHAKE_HASH = Uint8Array.from([0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0)]);23-24-// Skip the real handshake messages; encrypt/decrypt are identity transforms so25-// the channel's framing can be asserted directly.26-vi.mock("./noise-js", async (orig) => {27- const actual = await orig<typeof import("./noise-js")>();28- return {29- ...actual,30- noiseNKInitiator: vi.fn(async () => ({31- encrypt: (pt: Uint8Array) => pt,32- decrypt: (ct: Uint8Array) => ct,33- handshakeHash: FAKE_HANDSHAKE_HASH,34- close: vi.fn(),35- })),36- };37-});38-39-/** `null` in the queue means "the socket closes here with no data". */40-function fakeSocket(inbound: Array<Uint8Array | null>) {41- const sent: Uint8Array[] = [];42- let closed = false;43- let lastWasClose = false;44- const queue = [...inbound];45- const sock: PairingSocket = {46- send(bytes) { sent.push(bytes); },47- async receive() {48- const next = queue.shift();49- if (next === undefined || next === null) {50- closed = true;51- lastWasClose = true;52- throw new Error("socket closed");53- }54- lastWasClose = false;55- return next;56- },57- close() { closed = true; },58- closedBeforeFrame() { return lastWasClose; },59- };60- return { sock, sent, isClosed: () => closed };61-}62-63-function ackThen(...rest: Array<Uint8Array | null>) {64- return fakeSocket([65- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: LISTENER_EPHEMERAL_HEX }),66- ...rest,67- ]);68-}69-70-beforeEach(() => { vi.clearAllMocks(); });71-72-describe("dialPairingChannel", () => {73- it("dials the coded endpoint", async () => {74- const openSocket = vi.fn(async () => ackThen().sock);75-76- const result = await dialPairingChannel(CODE, { openSocket });77-78- expect(result.ok).toBe(true);79- expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);80- });81-82- it("presents the token as hex and returns the shared SAS", async () => {83- const s = ackThen();84- const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });85-86- expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });87- expect(result.ok && result.dialled.sas).toBe("44907520");88- expect(result.ok && result.dialled.host).toBe("192.168.4.77");89- expect(result.ok && result.dialled.rendezvousSecretHex).toBe(90- "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",91- );92- });93-94- it("marks LAN adoption before Noise so the source can skip device-pair auth", async () => {95- const s = ackThen();96- const result = await dialPairingChannel(97- CODE,98- { openSocket: async () => s.sock },99- { flow: "adoption" },100- );101-102- expect(result.ok).toBe(true);103- expect(dec(s.sent[0])).toEqual({104- type: "PEACH_HELLO",105- token: "deadbeef",106- flow: "adoption",107- });108- });109-110- it("rejects an addressless legacy code before opening a socket", async () => {111- const openSocket = vi.fn(async () => ackThen().sock);112-113- const result = await dialPairingChannel("123456", { openSocket });114-115- expect(result).toEqual({ ok: false, error: "invalid_code" });116- expect(openSocket).not.toHaveBeenCalled();117- });118-119- it("reads a silent close as invalid_code, not a transport failure", async () => {120- // A wrong or expired token gets no error frame — the listener just closes.121- const s = fakeSocket([null]);122- const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });123-124- expect(result).toEqual({ ok: false, error: "invalid_code" });125- expect(s.isClosed()).toBe(true);126- });127-128- it("treats a non-ACK first frame exactly like a wrong token", async () => {129- const s = fakeSocket([enc({ type: "SOMETHING_ELSE" })]);130- const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });131-132- expect(result).toEqual({ ok: false, error: "invalid_code" });133- });134-135- it("rejects an ACK whose ephemeral key is not 32-byte hex", async () => {136- const s = fakeSocket([enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: "nope" })]);137- const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });138-139- expect(result).toEqual({ ok: false, error: "handshake_failed" });140- expect(s.isClosed()).toBe(true);141- });142-143- it("reports no_host_found when the endpoint refuses the connection", async () => {144- const result = await dialPairingChannel(CODE, {145- openSocket: async () => { throw new Error("ECONNREFUSED"); },146- });147-148- expect(result).toEqual({ ok: false, error: "no_host_found" });149- });150-151- it("leaves the socket open on success so the caller owns its lifetime", async () => {152- const s = ackThen(enc({ hello: "world" }));153- const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });154- if (!result.ok) throw new Error("expected a dialled channel");155-156- expect(s.isClosed()).toBe(false);157- await result.dialled.channel.send(enc({ type: "ANYTHING" }));158- // sent[0] is the plaintext hello; sent[1] went through the Noise channel.159- expect(dec(s.sent[1])).toEqual({ type: "ANYTHING" });160- expect(dec(await result.dialled.channel.receive())).toEqual({ hello: "world" });161-162- result.dialled.socket.close();163- expect(s.isClosed()).toBe(true);164- });165-});166-167-describe("dialRelayPairingChannel", () => {168- it("occupies the invited relay room and runs the identical hello/NK path", async () => {169- const s = ackThen();170- const openSocket = vi.fn(async () => s.sock);171-172- const result = await dialRelayPairingChannel(RELAY_INVITATION, { openSocket });173-174- expect(result.ok).toBe(true);175- expect(openSocket).toHaveBeenCalledWith(176- `wss://relay.example.test/base/r/${RELAY_TOKEN}`,177- );178- expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: RELAY_TOKEN });179- expect(result.ok && result.dialled.relayUrl).toBe(180- "wss://relay.example.test/base",181- );182- expect(result.ok && result.dialled.host).toBe("relay.example.test");183- });184-185- it("rejects a malformed invitation before opening a socket", async () => {186- const openSocket = vi.fn(async () => ackThen().sock);187- const result = await dialRelayPairingChannel("peach-pair-v1:not-valid", {188- openSocket,189- });190- expect(result).toEqual({ ok: false, error: "invalid_code" });191- expect(openSocket).not.toHaveBeenCalled();192- });193-});src/shared/pairing-dial.ts+6 −36
@@ -19,7 +19,6 @@1919 type NoiseNKSession,2020 } from "./noise-js";2121 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22-import { parsePairingCode } from "./pairing-code";2322 import {2423 pairingInvitationUrl,2524 parsePairingInvitation,@@ -27,7 +26,12 @@2726 import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2827 2928 /** Default pairing port used by native listeners. */30-export const PAIRING_PORT = 49142;29+/**30+ * The port Android's reconnect listener used to accept on. Retained only31+ * because paired records may still carry a `wsPort`; nothing dials it. LAN32+ * transport is retired — see docs/sync-lan-retirement.md.33+ */34+export const LEGACY_LAN_PORT = 49142;3135 3236 export type PairingDialError =3337 | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)@@ -88,35 +92,6 @@8892 | { ok: true; dialled: DialledPairingChannel }8993 | { ok: false; error: PairingDialError };9094 91-/**92- * Dial a pairing code and bring up the encrypted channel behind it.93- *94- * On success the socket is left OPEN and owned by the caller, which must close95- * it. On failure it is closed here.96- */97-export async function dialPairingChannel(98- code: string,99- deps: PairingDialDeps,100- options: PairingDialOptions = {},101-): Promise<DialResult> {102- const pairing = parsePairingCode(code);103- if (!pairing) return { ok: false, error: "invalid_code" };104- const { ip: host, port } = pairing;105-106- let sock: PairingSocket;107- try {108- sock = await deps.openSocket(host, port);109- } catch {110- return { ok: false, error: "no_host_found" };111- }112-113- return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);114-}115-116-/**117- * Dial a relay-first invitation. The source must already occupy the room118- * because the relay deliberately buffers nothing.119- */12095 export async function dialRelayPairingChannel(12196 encodedInvitation: string,12297 deps: RelayPairingDialDeps,@@ -231,11 +206,6 @@231206 sock.close();232207 return { ok: false, error: "handshake_failed" };233208 }234-}235-236-/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */237-export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {238- return openPairingSocketUrl(`ws://${ip}:${port}/`);239209 }240210 241211 /** Relay-first pairing uses the same binary socket seam over `wss://`. */src/shared/sync-types.ts+5 −4
@@ -1,8 +1,9 @@1-// Vault sync — type definitions (Master Brief v2).1+// Vault sync — type definitions.22 //3-// v3 — code pairing once per device pair (Phase 1). All ongoing vault sync4-// after that runs over a direct WebSocket on the local network (Phase 2).5-// The two phases never overlap.3+// Pairing happens once per device pair over a relay rendezvous; all ongoing4+// sync afterwards runs over that same relay with Noise KK. There is one5+// transport — LAN peer-to-peer sync is retired6+// (docs/sync-lan-retirement.md).67 78 // ---------------------------------------------------------------------------89 // Device typessrc/shared/types.ts+4 −8
@@ -303,12 +303,8 @@303303 | "LIST_PAIRED_DEVICES"304304 | "UNPAIR_DEVICE"305305 | "RENAME_LOCAL_DEVICE"306- // Code pairing. PAIR_WITH_CODE / CODE_PAIR_CONFIRM are popup→bg requests307- // handled in the switch; CODE_PAIR_SAS / CODE_PAIR_RESULT are bg→popup pushes.308- | "PAIR_WITH_CODE"309- | "CODE_PAIR_CONFIRM"310- | "CODE_PAIR_SAS"311- | "CODE_PAIR_RESULT"306+ // Relay pairing. START/CONFIRM/CANCEL are popup→bg requests handled in the307+ // switch; ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT are bg→popup pushes.312308 | "START_ADOPTION_SOURCE"313309 | "ADOPTION_SOURCE_CONFIRM"314310 | "CANCEL_ADOPTION_SOURCE"@@ -316,8 +312,8 @@316312 | "ADOPTION_SOURCE_RESULT"317313 | "TRIGGER_SYNC"318314 | "GET_SYNC_STATE"319- | "GET_RELAY_SETTINGS"320- | "SET_RELAY_SETTINGS"315+ | "GET_SYNC_SETTINGS"316+ | "SET_SYNC_SETTINGS"321317 | "GET_PENDING_PASSKEY"322318 | "APPROVE_PASSKEY"323319 | "DENY_PASSKEY"