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
↓ peach-browser-4d35c2a9.tar.gzatlas at this save
files changed18 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 −0manifest.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": {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",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",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();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);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);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();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 }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+});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+}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}>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 () => {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 };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 }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+});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+}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 }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"