4d35c2a90536

feat: add rotating relay rendezvous state (v4.0.41)

time
2026-07-26 03:01 UTC · 3 months ago
machine
koh2
agent
codex
session
peach relay sync implementation
parent
3901ccee1835
18 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+4 −0src/background/code-pairing.test.ts+6 −0src/background/code-pairing.ts+14 −1src/background/messages.ts+30 −0src/background/paired-devices.ts+14 −0src/background/relay-settings.test.ts+54 −0src/background/relay-settings.ts+44 −0src/popup/views/Settings.tsx+83 −0src/shared/pairing-dial.test.ts+3 −0src/shared/pairing-dial.ts+4 −0src/shared/pairing-handshake.ts+34 −3src/shared/rendezvous.test.ts+72 −0src/shared/rendezvous.ts+123 −0src/shared/sync-types.ts+4 −0src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.40",4+  "version": "4.0.41",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.40",4+  "version": "4.0.41",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.40",3+  "version": "4.0.41",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing-bg.ts+4 −0
@@ -21,6 +21,7 @@2121 import { computeVaultId } from "../shared/sync-identity";2222 import { isUnlocked, getRootKeyHex } from "./vault";2323 import type { LocalIdentity } from "../shared/pairing-handshake";24+import { getRelaySettings } from "./relay-settings";2425 2526 function hexToBytesLocal(hex: string): Uint8Array {2627   const out = new Uint8Array(hex.length / 2);@@ -44,6 +45,9 @@4445 export function productionCodePairingDeps(): CodePairingDeps {4546   return {4647     openSocket: (ip, port) => openPairingSocket(ip, port),48+    async getRelayUrl() {49+      return (await getRelaySettings()).relayUrl;50+    },4751     async getLocalIdentity(): Promise<LocalIdentity> {4852       if (!isUnlocked()) throw new Error("vault_locked");4953       const masterKeyHex = getRootKeyHex();

view file

src/background/code-pairing.test.ts+6 −0
@@ -110,12 +110,14 @@110110           device_type: "android",111111           long_term_public_key_hex: PHONE_LONGTERM_HEX,112112           ws_port: 49142,113+          relay_url: "wss://relay.peachpasswords.com",113114         }),114115       ]);115116       sockets.push(s);116117       return s.sock;117118     }),118119     getLocalIdentity: vi.fn(async () => OUR_IDENTITY),120+    getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),119121     persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),120122     ...overrides,121123   };@@ -208,6 +210,8 @@208210     expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);209211     expect(persisted[0].trustSource).toBe("direct");210212     expect(persisted[0].wsPort).toBe(49142);213+    expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");214+    expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);211215     expect(completed).toHaveLength(1);212216   });213217 @@ -258,6 +262,7 @@258262     device_name: "Pixel 8",259263     long_term_public_key_hex: PHONE_LONGTERM_HEX,260264     ws_port: 49142,265+    relay_url: "wss://relay.peachpasswords.com",261266   };262267   identity.device_type = deviceType;263268   return makeDeps({@@ -292,6 +297,7 @@292297             device_type: "android",293298             long_term_public_key_hex: PHONE_LONGTERM_HEX,294299             ws_port: 49142,300+            relay_url: "wss://relay.peachpasswords.com",295301           }),296302         ]);297303         sockets.push(s);

view file

src/background/code-pairing.ts+14 −1
@@ -16,6 +16,7 @@1616 import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";1717 import type { LocalIdentity } from "../shared/pairing-handshake";1818 import type { DeviceType, PairedDevice } from "../shared/sync-types";19+import { normalizeRelayUrl } from "../shared/rendezvous";1920 2021 /** Default sync port a peer announces; used when ws_port is absent. Same2122  *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */@@ -31,6 +32,8 @@3132 export interface CodePairingDeps extends PairingDialDeps {3233   /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */3334   getLocalIdentity(): Promise<LocalIdentity>;35+  /** Current mesh relay URL; sent in the encrypted pairing identity. */36+  getRelayUrl(): Promise<string>;3437   /** Persist on user confirm. */3538   persistDevice(device: PairedDevice): Promise<void>;3639   /** Optional fast-path cache for the next sync round. */@@ -55,6 +58,7 @@5558   signing_public_key_hex?: string;5659   ws_port?: number;5760   vault_id?: string; // Change 1 — additive, optional61+  relay_url?: string;5862 }5963 6064 function encodeJson(obj: unknown): Uint8Array {@@ -77,6 +81,10 @@7781       !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {7882     throw new Error("long_term_public_key_hex is not 32-byte hex");7983   }84+  if (p.relay_url !== undefined) {85+    if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");86+    p.relay_url = normalizeRelayUrl(p.relay_url);87+  }8088   return p as unknown as PairingFrame;8189 }8290 @@ -108,9 +116,10 @@108116     cb.onError(dial.error);109117     return;110118   }111-  const { channel, sas, host, socket } = dial.dialled;119+  const { channel, sas, host, rendezvousSecretHex, socket } = dial.dialled;112120 113121   try {122+    const relayUrl = await deps.getRelayUrl();114123     // Initiator (browser) sends its identity FIRST, then reads the peer's.115124     const ourFrame: PairingFrame = {116125       type: "PAIR_IDENTITY",@@ -121,6 +130,7 @@121130       signing_public_key_hex: identity.signingPublicKeyHex,122131       ws_port: identity.wsPort,123132       vault_id: identity.vaultId, // Change 1133+      relay_url: relayUrl,124134     };125135     await channel.send(encodeJson(ourFrame));126136 @@ -143,6 +153,9 @@143153       signingPublicKeyHex: theirFrame.signing_public_key_hex,144154       trustSource: "direct",145155       wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,156+      rendezvousSecretHex,157+      // The listening phone is the authority for the relay this pair will use.158+      relayUrl: theirFrame.relay_url ?? relayUrl,146159       pairedAt: Date.now(),147160     };148161     await deps.persistDevice(device);

view file

src/background/messages.ts+30 −0
@@ -1471,6 +1471,36 @@14711471         return { ok: true, data: getSyncState() };14721472       }14731473 1474+      case "GET_RELAY_SETTINGS": {1475+        const { getRelaySettings } = await import("./relay-settings");1476+        return { ok: true, data: await getRelaySettings() };1477+      }1478+1479+      case "SET_RELAY_SETTINGS": {1480+        const payload = (msg.payload ?? {}) as {1481+          relayUrl?: string;1482+          lanOnly?: boolean;1483+        };1484+        const { getRelaySettings, setLanOnly, setRelayUrl } = await import("./relay-settings");1485+        try {1486+          if (payload.relayUrl !== undefined) {1487+            if (typeof payload.relayUrl !== "string") {1488+              return { ok: false, error: "invalid_relay_url" };1489+            }1490+            await setRelayUrl(payload.relayUrl);1491+          }1492+          if (payload.lanOnly !== undefined) {1493+            if (typeof payload.lanOnly !== "boolean") {1494+              return { ok: false, error: "invalid_lan_only" };1495+            }1496+            await setLanOnly(payload.lanOnly);1497+          }1498+          return { ok: true, data: await getRelaySettings() };1499+        } catch {1500+          return { ok: false, error: "invalid_relay_url" };1501+        }1502+      }1503+14741504       case "GET_SYNC_ALERTS": {14751505         const { consume } = (msg.payload ?? {}) as { consume?: boolean };14761506         const alerts = await getPendingSyncAlerts();

view file

src/background/paired-devices.ts+14 −0
@@ -176,5 +176,19 @@176176   const r = x as Record<string, unknown>;177177   if (typeof r.deviceId !== "string" || !r.deviceId) return false;178178   if (typeof r.pairedAt !== "number") return false;179+  if (180+    r.rendezvousSecretHex !== undefined &&181+    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))182+  ) {183+    return false;184+  }185+  if (r.relayUrl !== undefined) {186+    if (typeof r.relayUrl !== "string") return false;187+    try {188+      if (new URL(r.relayUrl).protocol !== "wss:") return false;189+    } catch {190+      return false;191+    }192+  }179193   return true;180194 }

view file

src/background/relay-settings.test.ts+54 −0
@@ -0,0 +1,54 @@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[]) =>18+          Object.fromEntries(keys.filter((key) => key in stored).map((key) => [key, stored[key]])),19+        ),20+        set: vi.fn(async (values: Record<string, unknown>) => {21+          Object.assign(stored, values);22+        }),23+      },24+    },25+  };26+});27+28+describe("relay settings", () => {29+  it("defaults to June's relay with internet fallback enabled", async () => {30+    await expect(getRelaySettings()).resolves.toEqual({31+      relayUrl: DEFAULT_RELAY_URL,32+      lanOnly: false,33+    });34+  });35+36+  it("persists a normalized self-hosted wss URL and LAN-only opt-out", async () => {37+    await expect(setRelayUrl("wss://relay.example.test/base/")).resolves.toBe(38+      "wss://relay.example.test/base",39+    );40+    await setLanOnly(true);41+    await expect(getRelaySettings()).resolves.toEqual({42+      relayUrl: "wss://relay.example.test/base",43+      lanOnly: true,44+    });45+  });46+47+  it("rejects ws and ignores an invalid stored URL", async () => {48+    await expect(setRelayUrl("ws://relay.example.test")).rejects.toThrow(/wss/);49+    stored.peach_relay_url_v1 = "https://not-a-websocket.example";50+    await expect(getRelaySettings()).resolves.toMatchObject({51+      relayUrl: DEFAULT_RELAY_URL,52+    });53+  });54+});

view file

src/background/relay-settings.ts+44 −0
@@ -0,0 +1,44 @@1+import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2+3+const RELAY_URL_KEY = "peach_relay_url_v1";4+const LAN_ONLY_KEY = "peach_sync_lan_only_v1";5+6+export interface RelaySettings {7+  relayUrl: string;8+  lanOnly: boolean;9+}10+11+export async function getRelaySettings(): Promise<RelaySettings> {12+  try {13+    const raw = await chrome.storage.local.get([RELAY_URL_KEY, LAN_ONLY_KEY]);14+    const values = raw as Record<string, unknown>;15+    const relayUrl =16+      typeof values[RELAY_URL_KEY] === "string"17+        ? safeNormalize(values[RELAY_URL_KEY] as string)18+        : DEFAULT_RELAY_URL;19+    return {20+      relayUrl,21+      lanOnly: values[LAN_ONLY_KEY] === true,22+    };23+  } catch {24+    return { relayUrl: DEFAULT_RELAY_URL, lanOnly: false };25+  }26+}27+28+export async function setRelayUrl(input: string): Promise<string> {29+  const relayUrl = normalizeRelayUrl(input);30+  await chrome.storage.local.set({ [RELAY_URL_KEY]: relayUrl });31+  return relayUrl;32+}33+34+export async function setLanOnly(lanOnly: boolean): Promise<void> {35+  await chrome.storage.local.set({ [LAN_ONLY_KEY]: lanOnly });36+}37+38+function safeNormalize(input: string): string {39+  try {40+    return normalizeRelayUrl(input);41+  } catch {42+    return DEFAULT_RELAY_URL;43+  }44+}

view file

src/popup/views/Settings.tsx+83 −0
@@ -269,6 +269,10 @@269269   const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270270   const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271271   const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272+  const [relayUrl, setRelayUrl] = createSignal("");273+  const [lanOnly, setLanOnly] = createSignal(false);274+  const [relaySaving, setRelaySaving] = createSignal(false);275+  const [relayStatus, setRelayStatus] = createSignal<string | null>(null);272276 273277   const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;274278   function syncRelativeTime(when: number | undefined): string {@@ -337,6 +341,36 @@337341       setTimeout(() => setSyncFlash(null), 4000);338342     }339343   }344+  async function loadRelaySettings() {345+    const result = (await sendMessage({ type: "GET_RELAY_SETTINGS" })) as {346+      ok: boolean;347+      data?: { relayUrl: string; lanOnly: boolean };348+    };349+    if (result.ok && result.data) {350+      setRelayUrl(result.data.relayUrl);351+      setLanOnly(result.data.lanOnly);352+    }353+  }354+  async function saveRelaySettings(nextLanOnly = lanOnly()) {355+    setRelaySaving(true);356+    setRelayStatus(null);357+    const result = (await sendMessage({358+      type: "SET_RELAY_SETTINGS",359+      payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly },360+    })) as {361+      ok: boolean;362+      error?: string;363+      data?: { relayUrl: string; lanOnly: boolean };364+    };365+    if (result.ok && result.data) {366+      setRelayUrl(result.data.relayUrl);367+      setLanOnly(result.data.lanOnly);368+      setRelayStatus("Saved");369+    } else {370+      setRelayStatus("Use a valid wss:// relay URL");371+    }372+    setRelaySaving(false);373+  }340374   // UI-4 / MV-8: rotate sync identity confirmation dialog.341375   const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);342376   const [rotating, setRotating] = createSignal(false);@@ -554,6 +588,7 @@554588     // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.555589     void loadPendingVouched();556590     void loadPairedDevices();591+    void loadRelaySettings();557592     const onBroadcast = (msg: unknown) => {558593       if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {559594         void loadPendingVouched();@@ -1563,6 +1598,54 @@15631598               At least one device needs Peach Pro — pair with a Pro device and it carries over to here.15641599             </p>15651600           </Show>1601+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>LAN only</span>1606+              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607+                Disable internet relay fallback. Nearby-device sync still works.1608+              </span>1609+            </div>1610+            <label class="peach-switch">1611+              <input1612+                type="checkbox"1613+                checked={lanOnly()}1614+                disabled={relaySaving()}1615+                onChange={(event) => {1616+                  const enabled = (event.target as HTMLInputElement).checked;1617+                  setLanOnly(enabled);1618+                  void saveRelaySettings(enabled);1619+                }}1620+              />1621+              <span class="peach-switch-track" />1622+            </label>1623+          </div>1624+          <label class="peach-label" for="sync-relay-url">relay URL</label>1625+          <input1626+            id="sync-relay-url"1627+            class="peach-underline-input"1628+            type="url"1629+            inputmode="url"1630+            autocomplete="off"1631+            spellcheck={false}1632+            value={relayUrl()}1633+            disabled={relaySaving()}1634+            onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1635+            onKeyDown={(event) => {1636+              if (event.key === "Enter") void saveRelaySettings();1637+            }}1638+          />1639+          <div class="peach-settings-row peach-settings-row--meta">1640+            <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1641+              {relayStatus() ?? "Only secure wss:// relays are accepted."}1642+            </span>1643+            <button1644+              class="peach-btn peach-btn--subtle peach-btn--sm"1645+              disabled={relaySaving()}1646+              onClick={() => void saveRelaySettings()}1647+            >{relaySaving() ? "saving…" : "save"}</button>1648+          </div>15661649 15671650           {/* Paired devices + manual sync — Settings is the hub. */}15681651           <Show when={pairedDevices().length > 0}>

view file

src/shared/pairing-dial.test.ts+3 −0
@@ -75,6 +75,9 @@7575     expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });7676     expect(result.ok && result.dialled.sas).toBe("895245");7777     expect(result.ok && result.dialled.host).toBe("192.168.4.77");78+    expect(result.ok && result.dialled.rendezvousSecretHex).toBe(79+      "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",80+    );7881   });7982 8083   it("rejects an addressless legacy code before opening a socket", async () => {

view file

src/shared/pairing-dial.ts+4 −0
@@ -16,6 +16,7 @@1616 import { noiseNKInitiator } from "./noise-js";1717 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";1818 import { parsePairingCode } from "./pairing-code";19+import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";1920 2021 /** Default pairing port used by native listeners. */2122 export const PAIRING_PORT = 49142;@@ -49,6 +50,8 @@4950   sas: string;5051   /** The address actually connected to, for the reconnect cache. */5152   host: string;53+  /** Pair-only reconnect capability derived independently from the SAS. */54+  rendezvousSecretHex: string;5255   socket: PairingSocket;5356 }5457 @@ -126,6 +129,7 @@126129         channel,127130         sas: derivePairingSAS(session.handshakeHash),128131         host,132+        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),129133         socket: sock,130134       },131135     };

view file

src/shared/pairing-handshake.ts+34 −3
@@ -31,6 +31,11 @@3131 3232 import { noiseNKInitiator, noiseNKResponder } from "./noise-js";3333 import type { DeviceType, PairedDevice } from "./sync-types";34+import {35+  bytesToHex,36+  deriveRendezvousSecret,37+  normalizeRelayUrl,38+} from "./rendezvous";3439 3540 export interface PairingTransport {3641   send(msg: Uint8Array): Promise<void>;@@ -72,6 +77,7 @@7277    * confirmed, so pairing carries Pro to a free device. Either direction.7378    */7479   license?: string;80+  relayUrl?: string;7581 }7682 7783 interface PairingFrame {@@ -88,16 +94,19 @@8894   // Optional signed Pro license blob — additive; peers that don't understand8995   // it ignore it. Activated by the receiver only after SAS confirmation.9096   license?: string;97+  relay_url?: string;9198 }9299 93100 export interface PostNoiseFrame {94101   sas: string;102+  rendezvousSecretHex: string;95103   frame: unknown;96104   channel: PairingTransport;97105 }98106 99107 export interface PostNoiseChannel {100108   sas: string;109+  rendezvousSecretHex: string;101110   channel: PairingTransport;102111 }103112 @@ -122,6 +131,10 @@122131   ) {123132     throw new Error("signing_public_key_hex is not 32-byte hex");124133   }134+  if (value.relay_url !== undefined) {135+    if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");136+    value.relay_url = normalizeRelayUrl(value.relay_url);137+  }125138   return value as unknown as PairingFrame;126139 }127140 @@ -150,13 +163,17 @@150163     ws_port: ourIdentity.wsPort,151164     vault_id: ourIdentity.vaultId,152165     license: ourIdentity.license,166+    relay_url: ourIdentity.relayUrl,153167   }));154168 155169   const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));156170   // The SAS is derived from the shared Noise handshake hash; the caller MUST157171   // have the user confirm it matches the peer's before persisting the device.158172   return {159-    device: frameToPairedDevice(theirFrame),173+    device: {174+      ...frameToPairedDevice(theirFrame),175+      rendezvousSecretHex: postNoise.rendezvousSecretHex,176+    },160177     sas: postNoise.sas,161178     peerLicense: peerLicenseOf(theirFrame),162179   };@@ -182,6 +199,7 @@182199   };183200   return {184201     sas: derivePairingSAS(session.handshakeHash),202+    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),185203     channel,186204   };187205 }@@ -262,7 +280,13 @@262280     ourEphemeralPublicKey,263281     transport,264282   );265-  return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);283+  return handlePairIdentityFrame(284+    postNoise.frame,285+    postNoise.channel,286+    ourIdentity,287+    postNoise.sas,288+    postNoise.rendezvousSecretHex,289+  );266290 }267291 268292 export async function runPairingResponderOpenFrame(@@ -288,6 +312,7 @@288312   const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));289313   return {290314     sas: derivePairingSAS(session.handshakeHash),315+    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),291316     frame: theirFrame,292317     channel,293318   };@@ -298,6 +323,7 @@298323   channel: PairingTransport,299324   ourIdentity: LocalIdentity,300325   sas: string,326+  rendezvousSecretHex: string,301327 ): Promise<PairingHandshakeResult> {302328   const theirFrame = decodeParsedFrame(frame);303329   const ourFrame = encodeFrame({@@ -310,6 +336,7 @@310336     ws_port: ourIdentity.wsPort,311337     vault_id: ourIdentity.vaultId,312338     license: ourIdentity.license,339+    relay_url: ourIdentity.relayUrl,313340   });314341   await channel.send(ourFrame);315342 @@ -317,7 +344,10 @@317344   // confirm it matches the peer's code before persisting (defeats the318345   // initiator-impersonation / MITM attack the NK handshake alone allows).319346   return {320-    device: frameToPairedDevice(theirFrame),347+    device: {348+      ...frameToPairedDevice(theirFrame),349+      rendezvousSecretHex,350+    },321351     sas,322352     peerLicense: peerLicenseOf(theirFrame),323353   };@@ -338,6 +368,7 @@338368     signingPublicKeyHex: frame.signing_public_key_hex,339369     trustSource: "direct",340370     wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,371+    relayUrl: frame.relay_url,341372     pairedAt: Date.now(),342373   };343374 }

view file

src/shared/rendezvous.test.ts+72 −0
@@ -0,0 +1,72 @@1+import { describe, expect, it, vi } from "vitest";2+3+import {4+  DEFAULT_RELAY_URL,5+  buildRendezvousUrl,6+  bytesToHex,7+  deriveRendezvousId,8+  deriveRendezvousSecret,9+  dialingRendezvousIds,10+  generatePairingRendezvousToken,11+  normalizeRelayUrl,12+  rendezvousSecretFromHex,13+  rendezvousWindowIndex,14+  waitingRendezvousIds,15+} from "./rendezvous";16+17+const HANDSHAKE_HASH = rendezvousSecretFromHex(18+  "0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655",19+);20+21+describe("relay rendezvous contract", () => {22+  it("derives the cross-platform secret and hourly id KATs", () => {23+    const secret = deriveRendezvousSecret(HANDSHAKE_HASH);24+    expect(bytesToHex(secret)).toBe(25+      "0803691c8ac7330883c0987e33cea4a171992366ce4b06b88691e84ca689773d",26+    );27+    expect(deriveRendezvousId(secret, 500_000)).toBe(28+      "9UY3j5AHnwYSApvymtY965su9n60rQOv8QZ2PZzHuKI",29+    );30+  });31+32+  it("uses one-hour windows with mandatory skew tolerance order", () => {33+    const secret = deriveRendezvousSecret(HANDSHAKE_HASH);34+    const now = 500_000 * 3600 + 3599;35+    expect(rendezvousWindowIndex(now)).toBe(500_000);36+    expect(waitingRendezvousIds(secret, now)).toEqual([37+      deriveRendezvousId(secret, 500_000),38+      deriveRendezvousId(secret, 499_999),39+    ]);40+    expect(dialingRendezvousIds(secret, now)).toEqual([41+      deriveRendezvousId(secret, 500_000),42+      deriveRendezvousId(secret, 499_999),43+      deriveRendezvousId(secret, 500_001),44+    ]);45+  });46+47+  it("generates a fresh 128-bit base64url first-pair token", () => {48+    vi.spyOn(crypto, "getRandomValues").mockImplementation((array) => {49+      (array as Uint8Array).set(Array.from({ length: 16 }, (_, i) => i));50+      return array;51+    });52+    expect(generatePairingRendezvousToken()).toBe("AAECAwQFBgcICQoLDA0ODw");53+  });54+55+  it("accepts only credential-free wss relay URLs", () => {56+    expect(normalizeRelayUrl(`${DEFAULT_RELAY_URL}/`)).toBe(DEFAULT_RELAY_URL);57+    expect(normalizeRelayUrl("wss://relay.example.test/base/")).toBe(58+      "wss://relay.example.test/base",59+    );60+    expect(() => normalizeRelayUrl("ws://relay.example.test")).toThrow(/wss/);61+    expect(() => normalizeRelayUrl("wss://user:[email protected]")).toThrow();62+    expect(() => normalizeRelayUrl("wss://relay.example.test?token=x")).toThrow();63+  });64+65+  it("builds the relay path without exposing identity", () => {66+    const id = "AAECAwQFBgcICQoLDA0ODw";67+    expect(buildRendezvousUrl(DEFAULT_RELAY_URL, id)).toBe(68+      `${DEFAULT_RELAY_URL}/r/${id}`,69+    );70+    expect(() => buildRendezvousUrl(DEFAULT_RELAY_URL, "short")).toThrow();71+  });72+});

view file

src/shared/rendezvous.ts+123 −0
@@ -0,0 +1,123 @@1+import { HKDF } from "@stablelib/hkdf";2+import { SHA256 } from "@stablelib/sha256";3+4+export const DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com";5+export const RENDEZVOUS_WINDOW_SECONDS = 3600;6+export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16;7+8+const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1");9+const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");10+const EMPTY_SALT = new Uint8Array(0);11+12+/** Derive pair-only reconnect state from the completed first-pair Noise hash. */13+export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array {14+  if (handshakeHash.length !== 32) {15+    throw new Error("handshake hash must be exactly 32 bytes");16+  }17+  return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32);18+}19+20+/**21+ * Derive one opaque hourly relay capability.22+ *23+ * Cross-client encoding contract:24+ *   info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)25+ *   id   = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))26+ */27+export function deriveRendezvousId(28+  rendezvousSecret: Uint8Array,29+  windowIndex: number,30+): string {31+  if (rendezvousSecret.length !== 32) {32+    throw new Error("rendezvous secret must be exactly 32 bytes");33+  }34+  if (!Number.isSafeInteger(windowIndex) || windowIndex < 0) {35+    throw new Error("window index must be a non-negative safe integer");36+  }37+  const info = new Uint8Array(WINDOW_INFO.length + 8);38+  info.set(WINDOW_INFO);39+  new DataView(info.buffer).setBigUint64(WINDOW_INFO.length, BigInt(windowIndex), false);40+  const derived = new HKDF(SHA256, rendezvousSecret, EMPTY_SALT, info).expand(32);41+  return base64urlEncode(derived);42+}43+44+export function rendezvousWindowIndex(45+  unixSeconds: number,46+  windowSeconds = RENDEZVOUS_WINDOW_SECONDS,47+): number {48+  if (!Number.isFinite(unixSeconds) || unixSeconds < 0) {49+    throw new Error("unixSeconds must be non-negative");50+  }51+  if (!Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {52+    throw new Error("windowSeconds must be a positive integer");53+  }54+  return Math.floor(unixSeconds / windowSeconds);55+}56+57+/** Waiting peers register current and previous windows for boundary tolerance. */58+export function waitingRendezvousIds(59+  secret: Uint8Array,60+  unixSeconds: number,61+): string[] {62+  const current = rendezvousWindowIndex(unixSeconds);63+  return [current, Math.max(0, current - 1)].map((window) =>64+    deriveRendezvousId(secret, window),65+  );66+}67+68+/** Dialers try current, previous, then next, matching the cross-client contract. */69+export function dialingRendezvousIds(70+  secret: Uint8Array,71+  unixSeconds: number,72+): string[] {73+  const current = rendezvousWindowIndex(unixSeconds);74+  return [current, Math.max(0, current - 1), current + 1].map((window) =>75+    deriveRendezvousId(secret, window),76+  );77+}78+79+/** Fresh, single-use first-pair bearer capability (128 bits). */80+export function generatePairingRendezvousToken(): string {81+  return base64urlEncode(crypto.getRandomValues(82+    new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),83+  ));84+}85+86+/** Accept only a credential-free `wss://` relay origin with an optional path. */87+export function normalizeRelayUrl(input: string): string {88+  const url = new URL(input.trim());89+  if (url.protocol !== "wss:") throw new Error("relay URL must use wss://");90+  if (url.username || url.password || url.search || url.hash) {91+    throw new Error("relay URL must not contain credentials, query, or fragment");92+  }93+  url.pathname = url.pathname.replace(/\/+$/, "");94+  return url.toString().replace(/\/$/, "");95+}96+97+export function buildRendezvousUrl(relayUrl: string, rendezvousId: string): string {98+  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {99+    throw new Error("invalid rendezvous id");100+  }101+  return `${normalizeRelayUrl(relayUrl)}/r/${rendezvousId}`;102+}103+104+export function bytesToHex(bytes: Uint8Array): string {105+  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");106+}107+108+export function rendezvousSecretFromHex(hex: string): Uint8Array {109+  if (!/^[0-9a-f]{64}$/.test(hex)) {110+    throw new Error("rendezvous secret must be 32-byte lowercase hex");111+  }112+  const out = new Uint8Array(32);113+  for (let i = 0; i < out.length; i++) {114+    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);115+  }116+  return out;117+}118+119+function base64urlEncode(bytes: Uint8Array): string {120+  let binary = "";121+  for (const byte of bytes) binary += String.fromCharCode(byte);122+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");123+}

view file

src/shared/sync-types.ts+4 −0
@@ -68,6 +68,10 @@6868    * pairing channel; the extension caches it here.6969    */7070   wsPort?: number;71+  /** Pair-only 32-byte reconnect capability, derived from the first handshake. */72+  rendezvousSecretHex?: string;73+  /** Validated wss relay used by this pair. */74+  relayUrl?: string;7175   pairedAt: number;7276   lastSyncedAt?: number;7377 }

view file

src/shared/types.ts+2 −0
@@ -311,6 +311,8 @@311311   | "CODE_PAIR_RESULT"312312   | "TRIGGER_SYNC"313313   | "GET_SYNC_STATE"314+  | "GET_RELAY_SETTINGS"315+  | "SET_RELAY_SETTINGS"314316   | "GET_PENDING_PASSKEY"315317   | "APPROVE_PASSKEY"316318   | "DENY_PASSKEY"

view file