76230f557838
wire START_PAIRING_SERVER, CONNECT_WITH_CODE, GET_RECENT_PAIRING handlers and auto-accept on active token
- time
- 2026-05-01 02:42 UTC · 5 months ago
- machine
- koh2
- parent
- f2b923812414
↓ peach-browser-76230f55.tar.gzatlas at this save
files changed6 files
src/background/messages.ts+120 −0src/background/pairing-server-state.ts+74 −0src/background/sync-identity-glue.ts+30 −0src/background/sync-lifecycle.ts+88 −16src/background/sync.ts+50 −2src/shared/types.ts+4 −0src/background/messages.ts+120 −0
@@ -103,6 +103,22 @@103103 }104104 105105 // ---------------------------------------------------------------------------106+// 6-digit code pairing.107+108+const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;109+110+function generateSixDigitToken(): string {111+ // crypto.getRandomValues, not Math.random — the token is the only secret112+ // protecting the pairing window from a same-LAN attacker. 6 decimal digits113+ // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating114+ // on the responder this is the right tradeoff between UX and security.115+ const buf = new Uint32Array(1);116+ crypto.getRandomValues(buf);117+ const value = buf[0] % 1_000_000;118+ return value.toString().padStart(6, "0");119+}120+121+// ---------------------------------------------------------------------------106122 // Peer discovery glue (Phase 7 popup pairing flow).107123 //108124 // The popup opens discovery via START_PEER_DISCOVERY. We connect to the@@ -859,6 +875,110 @@859875 // best-effort cleanup860876 }861877 return { ok: true };878+ }879+ case "START_PAIRING_SERVER": {880+ const gate = await requirePro("sync");881+ if (!gate.allowed) return { ok: false, error: "pro_required" };882+ try {883+ const sync = await import('./sync');884+ const { setActivePairingToken } = await import('./pairing-server-state');885+ const { getOrCreateLocalDeviceId } = await import('./sync-identity-glue');886+ sync.connectSyncHost();887+ const token = generateSixDigitToken();888+ const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;889+ const deviceId = await getOrCreateLocalDeviceId();890+ const name = await getLocalDeviceName();891+ await sync.startPairingServer({ token, deviceId, name });892+ await setActivePairingToken({ token, expiresAt });893+ return { ok: true, data: { token, expiresAt } };894+ } catch (err) {895+ return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };896+ }897+ }898+ case "STOP_PAIRING_SERVER": {899+ try {900+ const sync = await import('./sync');901+ const { setActivePairingToken } = await import('./pairing-server-state');902+ await setActivePairingToken(null);903+ if (sync.isHostConnected()) {904+ await sync.stopPairingServer();905+ }906+ } catch {907+ // best-effort cleanup908+ }909+ return { ok: true };910+ }911+ case "CONNECT_WITH_CODE": {912+ const gate = await requirePro("sync");913+ if (!gate.allowed) return { ok: false, error: "pro_required" };914+ const { token: rawToken } = msg.payload as { token: string };915+ const token = String(rawToken).replace(/\D/g, "");916+ if (token.length !== 6) return { ok: false, error: "invalid_code" };917+918+ const sync = await import('./sync');919+ const { encodeSyncMessage } = await import('../shared/sync-wire');920+ const { waitForPairingResponse } = await import('./sync-coordinator');921+ const { addPairedDevice } = await import('./sync-storage');922+ const { setRecentPairing } = await import('./pairing-server-state');923+924+ sync.connectSyncHost();925+ let connectionId: string | undefined;926+ try {927+ const peer = await sync.resolvePeerByCode(token, 8_000);928+ connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);929+930+ const ourPublicKeyHex = await sync.getIdentityPublicKey();931+ const deviceName = await getLocalDeviceName();932+933+ sync.sendSyncData(934+ connectionId,935+ encodeSyncMessage({936+ type: "PAIR_REQUEST",937+ public_key_hex: ourPublicKeyHex,938+ device_name: deviceName,939+ }),940+ );941+942+ const response = await waitForPairingResponse(connectionId, 30_000);943+944+ if (response.type === "PAIR_ACCEPT") {945+ await sync.trustDevice(response.public_key_hex, response.device_name);946+ await addPairedDevice({947+ deviceId: peer.deviceId,948+ displayName: response.device_name || peer.name,949+ x25519PublicKey: response.public_key_hex,950+ pairedAt: Date.now(),951+ lastSeenAt: Date.now(),952+ });953+ sync.disconnectFromPeer(connectionId);954+ // Stash for the popup so a popup-close mid-handshake still surfaces955+ // a confirmation on next open.956+ await setRecentPairing({957+ deviceName: response.device_name || peer.name,958+ pairedAt: Date.now(),959+ side: "entering",960+ });961+ return { ok: true, data: { deviceName: response.device_name || peer.name } };962+ }963+964+ if (response.type === "PAIR_REJECT") {965+ sync.disconnectFromPeer(connectionId);966+ return { ok: false, error: "pair_rejected", data: { reason: response.reason } };967+ }968+969+ sync.disconnectFromPeer(connectionId);970+ return { ok: false, error: "unexpected_response" };971+ } catch (err) {972+ if (connectionId) {973+ try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }974+ }975+ return { ok: false, error: err instanceof Error ? err.message : "connect_failed" };976+ }977+ }978+ case "GET_RECENT_PAIRING": {979+ const { consumeRecentPairing } = await import('./pairing-server-state');980+ const recent = await consumeRecentPairing();981+ return { ok: true, data: recent };862982 }863983 case "INITIATE_PAIR_WITH_PEER": {864984 const gate = await requirePro("sync");src/background/pairing-server-state.ts+74 −0
@@ -0,0 +1,74 @@1+/**2+ * Tracks the active 6-digit pairing-server session.3+ *4+ * While a token is active, `setupIncomingPairingSubscriber` auto-accepts the5+ * first PAIR_REQUEST that arrives (the user's act of showing the code is the6+ * consent — no banner). The token expires after `expiresAt` so a stale7+ * pairing window cannot silently accept a peer minutes later.8+ *9+ * On a successful pairing while the popup is closed, the responder records a10+ * `RecentPairing` so the next popup open can flash a "Paired with X"11+ * confirmation. The record is read-once.12+ */13+14+const ACTIVE_TOKEN_KEY = "peach_active_pairing_token";15+const RECENT_PAIRING_KEY = "peach_recent_pairing";16+17+export interface ActivePairingToken {18+ token: string;19+ expiresAt: number;20+}21+22+export interface RecentPairing {23+ deviceName: string;24+ pairedAt: number;25+ /** "showing" — we showed the code; "entering" — we typed a code. */26+ side: "showing" | "entering";27+}28+29+export async function setActivePairingToken(value: ActivePairingToken | null): Promise<void> {30+ try {31+ if (value === null) {32+ await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);33+ } else {34+ await chrome.storage.session.set({ [ACTIVE_TOKEN_KEY]: value });35+ }36+ } catch {37+ // session storage may be unavailable in test contexts38+ }39+}40+41+export async function getActivePairingToken(): Promise<ActivePairingToken | null> {42+ try {43+ const result = await chrome.storage.session.get(ACTIVE_TOKEN_KEY);44+ const value = (result as Record<string, ActivePairingToken | undefined>)[ACTIVE_TOKEN_KEY];45+ if (!value) return null;46+ if (Date.now() >= value.expiresAt) {47+ await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);48+ return null;49+ }50+ return value;51+ } catch {52+ return null;53+ }54+}55+56+export async function setRecentPairing(value: RecentPairing): Promise<void> {57+ try {58+ await chrome.storage.session.set({ [RECENT_PAIRING_KEY]: value });59+ } catch {60+ // best-effort61+ }62+}63+64+export async function consumeRecentPairing(): Promise<RecentPairing | null> {65+ try {66+ const result = await chrome.storage.session.get(RECENT_PAIRING_KEY);67+ const value = (result as Record<string, RecentPairing | undefined>)[RECENT_PAIRING_KEY];68+ if (!value) return null;69+ await chrome.storage.session.remove(RECENT_PAIRING_KEY);70+ return value;71+ } catch {72+ return null;73+ }74+}src/background/sync-identity-glue.ts+30 −0
@@ -0,0 +1,30 @@1+/**2+ * Local device id helpers shared between the lifecycle tick and the3+ * 6-digit pairing flow. Both need the same base64url-encoded 16-byte id4+ * the native host expects in `Advertise` / `StartPairingServer`.5+ */6+7+const DEVICE_ID_KEY = "peach_device_id";8+9+export async function getOrCreateLocalDeviceId(): Promise<string> {10+ try {11+ const stored = await chrome.storage.local.get(DEVICE_ID_KEY);12+ const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];13+ if (typeof value === "string" && value) return value;14+ } catch {15+ // fall through to generation16+ }17+ const random = crypto.getRandomValues(new Uint8Array(16));18+ let binary = "";19+ for (const byte of random) binary += String.fromCharCode(byte);20+ const id = btoa(binary)21+ .replace(/\+/g, "-")22+ .replace(/\//g, "_")23+ .replace(/=+$/g, "");24+ try {25+ await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });26+ } catch {27+ // best-effort28+ }29+ return id;30+}src/background/sync-lifecycle.ts+88 −16
@@ -18,8 +18,13 @@1818 import * as sync from "./sync";1919 import { isProUser } from "./license";2020 import * as vault from "./vault";21-import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22-import { decodeSyncMessage } from "../shared/sync-wire";21+import { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22+import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23+import {24+ getActivePairingToken,25+ setActivePairingToken,26+ setRecentPairing,27+} from "./pairing-server-state";2328 2429 const DEVICE_ID_KEY = "peach_device_id";2530 const DEVICE_NAME_KEY = "peach_device_name";@@ -222,20 +227,7 @@222227 return;223228 }224229 225- const now = Date.now();226- void setIncomingPairingRequest({227- deviceName,228- publicKeyHex,229- receivedAt: now,230- expiresAt: now + 60_000,231- connectionId: info.connectionId,232- });233-234- // Tell the popup. If it isn't open the runtime message no-ops; the235- // request still sits in session storage for the next popup open.236- chrome.runtime237- .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })238- .catch(() => {});230+ void completePairRequest(info.connectionId, publicKeyHex, deviceName);239231 });240232 241233 // Auto-cleanup if the connection drops before any frame arrives.@@ -247,6 +239,76 @@247239 });248240 }249241 242+/**243+ * Either auto-accept (active 6-digit code session) or queue the banner.244+ * The user's act of showing a code IS the consent, so a PAIR_REQUEST that245+ * arrives within an active code window does not need a second confirmation.246+ */247+async function completePairRequest(248+ connectionId: string,249+ publicKeyHex: string,250+ deviceName: string,251+): Promise<void> {252+ const activeToken = await getActivePairingToken();253+ if (activeToken) {254+ try {255+ const ourKey = await sync.getIdentityPublicKey();256+ const ourName = await getDeviceName();257+ sync.sendSyncData(258+ connectionId,259+ encodeSyncMessage({260+ type: "PAIR_ACCEPT",261+ public_key_hex: ourKey,262+ device_name: ourName,263+ }),264+ );265+ await sync.trustDevice(publicKeyHex, deviceName);266+ try {267+ await addPairedDevice({268+ deviceId: publicKeyHex,269+ displayName: deviceName,270+ x25519PublicKey: publicKeyHex,271+ pairedAt: Date.now(),272+ lastSeenAt: Date.now(),273+ });274+ } catch {275+ // Already paired — fine, idempotent.276+ }277+ await setRecentPairing({278+ deviceName,279+ pairedAt: Date.now(),280+ side: "showing",281+ });282+ // One code = one pairing. Clear the token so a second peer can't283+ // sneak in on the same window.284+ await setActivePairingToken(null);285+ try { sync.stopPairingServer(); } catch { /* best-effort */ }286+ chrome.runtime287+ .sendMessage({ type: "PAIRING_COMPLETED", payload: { deviceName } })288+ .catch(() => {});289+ } catch (err) {290+ console.warn("auto-accept pairing failed:", err);291+ } finally {292+ try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }293+ }294+ return;295+ }296+297+ // No active code — fall back to the legacy banner flow (kept as a no-op298+ // unless something off-flow drops a pairing-only connection on us).299+ const now = Date.now();300+ await setIncomingPairingRequest({301+ deviceName,302+ publicKeyHex,303+ receivedAt: now,304+ expiresAt: now + 60_000,305+ connectionId,306+ });307+ chrome.runtime308+ .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })309+ .catch(() => {});310+}311+250312 async function runResponderSession(connectionId: string): Promise<void> {251313 try {252314 const { createNativeSyncTransport, runBlobSyncSession } = await import(@@ -295,6 +357,16 @@295357 }296358 297359 export async function onVaultLocked(): Promise<void> {360+ // A locked Peach must not be accepting new trust relationships — tear down361+ // any active 6-digit pairing window before reconciling mDNS.362+ try {363+ await setActivePairingToken(null);364+ if (nativeHostConnected) {365+ try { await sync.stopPairingServer(); } catch { /* best-effort */ }366+ }367+ } catch {368+ // best-effort369+ }298370 await syncLifecycleTick();299371 }300372 src/background/sync.ts+50 −2
@@ -37,7 +37,10 @@3737 | { type: "GET_SESSION_KEY"; connection_id: string }3838 | { type: "STATUS" }3939 | { type: "ENTER_PAIRING_MODE" }40- | { type: "EXIT_PAIRING_MODE" };40+ | { type: "EXIT_PAIRING_MODE" }41+ | { type: "START_PAIRING_SERVER"; token: string; device_id: string; name: string }42+ | { type: "STOP_PAIRING_SERVER" }43+ | { type: "RESOLVE_PEER_BY_CODE"; token: string; timeout_ms?: number };4144 4245 type HostPeer = {4346 host: string;@@ -87,7 +90,10 @@8790 }8891 | { type: "ERROR"; connection_id?: string | null; message: string }8992 | { type: "PAIRING_MODE_ENTERED" }90- | { type: "PAIRING_MODE_EXITED" };93+ | { type: "PAIRING_MODE_EXITED" }94+ | { type: "PAIRING_SERVER_STARTED"; tcp_port: number; ws_port?: number | null }95+ | { type: "STOP_PAIRING_SERVER_OK" }96+ | { type: "PEER_RESOLVED"; peer: HostPeer };9197 9298 export interface PeerInfo {9399 host: string;@@ -416,6 +422,48 @@416422 417423 export async function exitPairingMode(): Promise<void> {418424 await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);425+}426+427+export interface PairingServerInfo {428+ tcpPort: number;429+ wsPort?: number;430+}431+432+export async function startPairingServer(options: {433+ token: string;434+ deviceId: string;435+ name: string;436+}): Promise<PairingServerInfo> {437+ const response = await requestFromHost<Extract<HostResponse, { type: "PAIRING_SERVER_STARTED" }>>(438+ {439+ type: "START_PAIRING_SERVER",440+ token: options.token,441+ device_id: options.deviceId,442+ name: options.name,443+ },444+ ["PAIRING_SERVER_STARTED"],445+ 15_000,446+ );447+ return {448+ tcpPort: response.tcp_port,449+ wsPort: response.ws_port ?? undefined,450+ };451+}452+453+export async function stopPairingServer(): Promise<void> {454+ await requestFromHost({ type: "STOP_PAIRING_SERVER" }, ["STOP_PAIRING_SERVER_OK"]);455+}456+457+export async function resolvePeerByCode(458+ token: string,459+ timeoutMs = 8_000,460+): Promise<PeerInfo> {461+ const response = await requestFromHost<Extract<HostResponse, { type: "PEER_RESOLVED" }>>(462+ { type: "RESOLVE_PEER_BY_CODE", token, timeout_ms: timeoutMs },463+ ["PEER_RESOLVED"],464+ timeoutMs + 2_000,465+ );466+ return mapPeer(response.peer);419467 }420468 421469 /**src/shared/types.ts+4 −0
@@ -253,6 +253,10 @@253253 | "START_PEER_DISCOVERY"254254 | "STOP_PEER_DISCOVERY"255255 | "INITIATE_PAIR_WITH_PEER"256+ | "START_PAIRING_SERVER"257+ | "STOP_PAIRING_SERVER"258+ | "CONNECT_WITH_CODE"259+ | "GET_RECENT_PAIRING"256260 | "GET_PENDING_PASSKEY"257261 | "APPROVE_PASSKEY"258262 | "DENY_PASSKEY"