da79fbcb8f0e

encrypt pair rendezvous secrets under vault-root wrapping key (PB-26-213, v5.0.60)

time
2026-09-03 10:18 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-213 pair-secret encryption
parent
b8f0394669b3
17 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/mesh-relay-listener.test.ts+4 −1src/background/mesh-relay-listener.ts+7 −2src/background/messages.ts+15 −0src/background/pair-secrets.test.ts+251 −0src/background/pair-secrets.ts+280 −0src/background/paired-devices.ts+22 −1src/background/relay-adoption-bg.test.ts+27 −0src/background/relay-adoption-bg.ts+26 −2src/background/sync-runner.test.ts+19 −0src/background/sync-runner.ts+11 −2src/background/sync-vault-adapter.test.ts+25 −6src/background/sync-vault-adapter.ts+7 −5src/background/vault.ts+27 −1src/shared/mesh-peer-role.ts+6 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.59",4+  "version": "5.0.60",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.59",5+  "version": "5.0.60",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/mesh-relay-listener.test.ts+4 −1
@@ -46,9 +46,12 @@4646     const key = "ee".repeat(32);4747     expect(isRouteCapableMeshPeer(peer("direct", key, { trustSource: "direct" }))).toBe(true);4848     expect(isRouteCapableMeshPeer(peer("vouched", key))).toBe(true);49+    // PB-26-213: the public registry carries no reusable secrets, so their50+    // absence no longer affects route capability — the secret resolves from51+    // the encrypted pair-secret record (or trusted session) at dial time.4952     expect(isRouteCapableMeshPeer(peer("missing-secret", key, {5053       rendezvousSecretHex: undefined,51-    }))).toBe(false);54+    }))).toBe(true);5255     expect(isRouteCapableMeshPeer(peer("printer", key, {5356       deviceType: "printer" as PairedDevice["deviceType"],5457     }))).toBe(false);

view file

src/background/mesh-relay-listener.ts+7 −2
@@ -27,7 +27,8 @@2727   markLastSyncedAt,2828 } from "./paired-devices";2929 import { getSyncSettings, relayInForce } from "./sync-settings";30-import { isUnlocked } from "./vault";30+import { isUnlocked, getRootKeyHex } from "./vault";31+import { getPairSecret } from "./pair-secrets";3132 3233 const RECONCILE_INTERVAL_MS = 60_000;3334 @@ -118,7 +119,11 @@118119         continue;119120       }120121       const relayUrl = peer.relayUrl ?? fallbackRelay;121-      const secret = rendezvousSecretFromHex(peer.rendezvousSecretHex!);122+      // PB-26-213: the registry carries no secrets; a peer without a123+      // resolvable secret is skipped until the next unlock reconciles it.124+      const secretHex = await getPairSecret(peer.deviceId, isUnlocked() ? getRootKeyHex() : null);125+      if (!secretHex) continue;126+      const secret = rendezvousSecretFromHex(secretHex);122127       for (const rendezvousId of waitingRendezvousIds(secret, nowSeconds)) {123128         desired.set(`${peer.deviceId}|${relayUrl}|${rendezvousId}`, {124129           peer,

view file

src/background/messages.ts+15 −0
@@ -81,6 +81,7 @@8181   revokeDeviceById as revokeDeviceByIdV3,8282   upsertPairedDevice as upsertPairedDeviceV3,8383 } from "./paired-devices";84+import { dropPairSecret } from "./pair-secrets";8485 // Sync and pairing are imported statically, never with `await import()`.8586 // `import()` throws `TypeError: import() is disallowed on8687 // ServiceWorkerGlobalScope` at runtime in MV3, and Vite's preload helper@@ -1553,6 +1554,20 @@15531554         // row: a plain removal is undone the moment any other peer vouches it15541555         // back through a manifest, which is exactly what the relay makes easy.15551556         await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1557+        // PB-26-213: forget the secret now so no further dial can use it.1558+        // Removals that happen while locked leave orphans for the next1559+        // unlock's reconcile to prune.1560+        const unpairRootHex = vault.getRootKeyHex();1561+        if (unpairRootHex) {1562+          const unpairRoot = unhexBytes(unpairRootHex);1563+          try {1564+            await dropPairSecret(deviceId, unpairRoot);1565+          } finally {1566+            unpairRoot.fill(0);1567+          }1568+        } else {1569+          await dropPairSecret(deviceId, null);1570+        }15561571         // Sync against the remaining hosts — the unpaired device no longer15571572         // matters but the next alarm tick is up to ten minutes away.15581573         void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);

view file

src/background/pair-secrets.test.ts+251 −0
@@ -0,0 +1,251 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+import { isRouteCapableMeshPeer } from "../shared/mesh-peer-role";4+import type { PairedDevice } from "../shared/sync-types";5+6+// PB-26-213: the reusable rendezvous secret must never sit in plaintext in7+// chrome.storage.local. It lives in an authenticated encrypted record under8+// a vault-root-derived wrapping key, with only a trusted-session operational9+// copy outside ciphertext.10+11+type StorageShape = Record<string, unknown>;12+13+const SECRET_A = "aa".repeat(32);14+const SECRET_B = "bb".repeat(32);15+const ROOT_KEY = new Uint8Array(32).fill(0x42);16+const WRONG_KEY = new Uint8Array(32).fill(0x99);17+18+function publicRow(deviceId: string): PairedDevice {19+  return {20+    deviceId,21+    deviceName: deviceId,22+    deviceType: "android",23+    longTermPublicKeyHex: "ab".repeat(32),24+    signingPublicKeyHex: "cd".repeat(32),25+    trustSource: "direct",26+    relayUrl: "wss://relay.example.com",27+    pairedAt: 1_700_000_000_000,28+  };29+}30+31+function legacyRow(deviceId: string, secret: string): PairedDevice {32+  return { ...publicRow(deviceId), rendezvousSecretHex: secret };33+}34+35+function rawLocalContainsSecret(local: StorageShape, secret: string): boolean {36+  return JSON.stringify(local).includes(secret);37+}38+39+describe("encrypted pair secrets (PB-26-213)", () => {40+  let local: StorageShape;41+  let session: StorageShape;42+43+  beforeEach(() => {44+    vi.resetModules();45+    local = {};46+    session = {};47+    const area = (store: StorageShape) => ({48+      get: vi.fn(async (key?: string | string[]) => {49+        if (!key) return store;50+        if (typeof key === "string") return { [key]: store[key] };51+        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});52+      }),53+      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),54+      remove: vi.fn(async (keys: string | string[]) => {55+        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];56+      }),57+      clear: vi.fn(async () => {58+        for (const k of Object.keys(store)) delete store[k];59+      }),60+    });61+    (globalThis as { chrome?: typeof chrome }).chrome = {62+      storage: { local: area(local), session: area(session) },63+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },64+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },65+    } as unknown as typeof chrome;66+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));67+  });68+69+  it("fresh pairing writes no reusable secret to storage.local outside ciphertext", async () => {70+    const { upsertPairedDevice } = await import("./paired-devices");71+    const { setPairSecret, getPairSecret } = await import("./pair-secrets");72+73+    await upsertPairedDevice(legacyRow("pixel-1", SECRET_A));74+    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);75+76+    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);77+    expect(local.peach_pair_secrets_v1).toBeDefined();78+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);79+  });80+81+  it("upsertPairedDevice never persists a rendezvous secret into the public registry", async () => {82+    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");83+84+    await upsertPairedDevice(legacyRow("pixel-1", SECRET_A));85+86+    const rows = await listPairedDevices();87+    expect(rows).toHaveLength(1);88+    expect(rows[0]!.rendezvousSecretHex).toBeUndefined();89+    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);90+  });91+92+  it("migration preserves every valid secret and scrubs plaintext only after verified persistence", async () => {93+    const { listPairedDevices } = await import("./paired-devices");94+    const { reconcilePairSecrets, getPairSecret } = await import("./pair-secrets");95+96+    local.peach_paired_devices_v3 = [legacyRow("pixel-1", SECRET_A), legacyRow("pixel-2", SECRET_B)];97+98+    const first = await reconcilePairSecrets(ROOT_KEY);99+    expect(first.migrated).toBe(2);100+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);101+    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_B);102+103+    // Plaintext source is gone only now that the encrypted write verified.104+    for (const row of await listPairedDevices()) {105+      expect(row.rendezvousSecretHex).toBeUndefined();106+    }107+    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);108+    expect(rawLocalContainsSecret(local, SECRET_B)).toBe(false);109+110+    // Idempotent across failures between steps: a second run changes nothing.111+    const second = await reconcilePairSecrets(ROOT_KEY);112+    expect(second.migrated).toBe(0);113+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);114+  });115+116+  it("a wrong wrapping key cannot read the map", async () => {117+    const { setPairSecret, getPairSecret } = await import("./pair-secrets");118+119+    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);120+    // Drop the trusted-session copy so only the crypto boundary remains.121+    for (const k of Object.keys(session)) delete session[k];122+    const wrongHex = Array.from(WRONG_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");123+    expect(await getPairSecret("pixel-1", wrongHex)).toBeNull();124+  });125+126+  it("unlock populates the trusted-session cache; a wiped session requires unlock", async () => {127+    const { setPairSecret, getPairSecret } = await import("./pair-secrets");128+129+    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);130+    // Locked (no root key): the session-scoped operational copy serves sync.131+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);132+133+    // Full browser restart clears the session: sync stays dormant until unlock.134+    for (const k of Object.keys(session)) delete session[k];135+    expect(await getPairSecret("pixel-1", null)).toBeNull();136+137+    // ...but an unlocked caller still reads through the root key.138+    const rootHex = Array.from(ROOT_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");139+    expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A);140+  });141+142+  it("unpair prunes the secret; re-pair replaces it without orphan records", async () => {143+    const { removePairedDeviceById, listPairedDevices } = await import("./paired-devices");144+    const { reconcilePairSecrets, setPairSecret, getPairSecret } = await import("./pair-secrets");145+146+    local.peach_paired_devices_v3 = [publicRow("pixel-1"), publicRow("pixel-2")];147+    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);148+    await setPairSecret("pixel-2", SECRET_B, ROOT_KEY);149+150+    await removePairedDeviceById("pixel-2");151+    expect((await listPairedDevices()).map((d) => d.deviceId)).toEqual(["pixel-1"]);152+    await reconcilePairSecrets(ROOT_KEY);153+    expect(await getPairSecret("pixel-2", null)).toBeNull();154+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);155+156+    await setPairSecret("pixel-2", SECRET_A, ROOT_KEY);157+    local.peach_paired_devices_v3 = [...(local.peach_paired_devices_v3 as PairedDevice[]), publicRow("pixel-2")];158+    await reconcilePairSecrets(ROOT_KEY);159+    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_A);160+  });161+162+  it("registry rows without secrets stay route-capable; the secret resolves at dial time", () => {163+    expect(isRouteCapableMeshPeer(publicRow("pixel-1"))).toBe(true);164+  });165+});166+167+describe("pair-secret vault lifecycle (PB-26-213)", () => {168+  let local: StorageShape;169+  let session: StorageShape;170+171+  beforeEach(() => {172+    vi.resetModules();173+    local = {};174+    session = {};175+    const area = (store: StorageShape) => ({176+      get: vi.fn(async (key?: string | string[]) => {177+        if (!key) return store;178+        if (typeof key === "string") return { [key]: store[key] };179+        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});180+      }),181+      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),182+      remove: vi.fn(async (keys: string | string[]) => {183+        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];184+      }),185+      clear: vi.fn(async () => {186+        for (const k of Object.keys(store)) delete store[k];187+      }),188+    });189+    (globalThis as { chrome?: typeof chrome }).chrome = {190+      storage: { local: area(local), session: area(session) },191+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },192+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },193+    } as unknown as typeof chrome;194+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));195+  });196+197+  function hexToBytes(value: string): Uint8Array {198+    const out = new Uint8Array(value.length / 2);199+    for (let i = 0; i < value.length; i += 2) out[i / 2] = parseInt(value.slice(i, i + 2), 16);200+    return out;201+  }202+203+  it("password change retains access; rotation clears the map", async () => {204+    const vault = await import("./vault");205+    const { getPairSecret, setPairSecret } = await import("./pair-secrets");206+    await vault.createVault("strong-pass-abc123");207+    await vault.unlock("strong-pass-abc123");208+209+    const root = hexToBytes(vault.getRootKeyHex()!);210+    try {211+      await setPairSecret("pixel-1", SECRET_A, root);212+    } finally {213+      root.fill(0);214+    }215+216+    expect(await vault.changePassword("strong-pass-abc123", "new-pass-xyz789")).toBe(true);217+    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBe(SECRET_A);218+219+    expect((await vault.rotateSyncIdentity()).ok).toBe(true);220+    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBeNull();221+    expect(local.peach_pair_secrets_v1).toBeUndefined();222+    expect(session.peach_pair_secrets_session).toBeUndefined();223+  });224+225+  it("decoy unlock drops the session copy; real re-unlock restores service", async () => {226+    const vault = await import("./vault");227+    const { upsertPairedDevice } = await import("./paired-devices");228+    const { getPairSecret, setPairSecret } = await import("./pair-secrets");229+    await vault.createVault("strong-pass-abc123");230+    await vault.unlock("strong-pass-abc123");231+232+    await upsertPairedDevice(publicRow("pixel-1"));233+    const root = hexToBytes(vault.getRootKeyHex()!);234+    try {235+      await setPairSecret("pixel-1", SECRET_A, root);236+    } finally {237+      root.fill(0);238+    }239+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);240+241+    await vault.setupDuress("decoy-password-67890");242+    await vault.lock();243+    expect(await vault.unlock("decoy-password-67890")).toBe(true);244+    expect(await getPairSecret("pixel-1", null)).toBeNull();245+    expect(session.peach_pair_secrets_session).toBeUndefined();246+247+    await vault.lock();248+    expect(await vault.unlock("strong-pass-abc123")).toBe(true);249+    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);250+  });251+});

view file

src/background/pair-secrets.ts+280 −0
@@ -0,0 +1,280 @@1+/**2+ * pair-secrets.ts — PB-26-213: the reusable rendezvous secret must never sit3+ * in plaintext in chrome.storage.local.4+ *5+ * Public routing metadata stays in the `peach_paired_devices_v3` registry;6+ * the secrets live in a separate AES-256-GCM record under a wrapping key7+ * derived from the vault root key (HKDF-SHA256, domain-separated label).8+ * Only a trusted-session operational copy exists outside ciphertext, so9+ * locked sync keeps working for the browser session without any disk copy.10+ *11+ * This module never imports vault.ts (vault.ts imports this module): every12+ * function that needs root-key material takes it as a parameter.13+ */14+import { HKDF } from "@stablelib/hkdf";15+import { SHA256 } from "@stablelib/sha256";16+17+import { decrypt, encrypt, zeroKey } from "./aes";18+import { listPairedDevices, writePairedDevices } from "./paired-devices";19+20+const RECORD_KEY = "peach_pair_secrets_v1";21+const SESSION_KEY = "peach_pair_secrets_session";22+const RECORD_VERSION = 1;23+const AAD = "peach-pair-secrets-v1";24+const WRAP_INFO = new TextEncoder().encode("peach-pair-secrets-v1");25+const EMPTY_SALT = new Uint8Array(0);26+27+export type PairSecretMap = Record<string, string>;28+29+// PB-26-203: the session operational copy is only written/read where startup30+// positively established trusted-only session storage. Defaults to allowed31+// so direct imports keep working; vault.applyStorageHardening sets the real32+// value before the first restore.33+let sessionAllowed = true;34+35+export function setSessionAllowed(allowed: boolean): void {36+  sessionAllowed = allowed;37+}38+39+export function isValidPairSecret(value: unknown): value is string {40+  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);41+}42+43+function wrappingKey(rootKey: Uint8Array): Uint8Array {44+  if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid");45+  return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32);46+}47+48+function hexToBytes(value: string): Uint8Array {49+  const out = new Uint8Array(value.length / 2);50+  for (let i = 0; i < value.length; i += 2) {51+    out[i / 2] = parseInt(value.slice(i, i + 2), 16);52+  }53+  return out;54+}55+56+type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string };57+58+function isEnvelope(value: unknown): value is SecretEnvelope {59+  if (typeof value !== "object" || value === null) return false;60+  const r = value as Record<string, unknown>;61+  return (62+    r.v === RECORD_VERSION &&63+    typeof r.nonce === "string" &&64+    typeof r.ciphertext === "string" &&65+    typeof r.tag === "string"66+  );67+}68+69+function sanitizeMap(value: unknown): PairSecretMap {70+  if (typeof value !== "object" || value === null) return {};71+  const out: PairSecretMap = {};72+  for (const [deviceId, secret] of Object.entries(value as Record<string, unknown>)) {73+    if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) {74+      out[deviceId] = secret;75+    }76+  }77+  return out;78+}79+80+async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {81+  let stored: unknown;82+  try {83+    const result = await chrome.storage.local.get(RECORD_KEY);84+    stored = (result as Record<string, unknown>)[RECORD_KEY];85+  } catch {86+    return {};87+  }88+  if (stored === undefined) return {};89+  if (!isEnvelope(stored)) return {};90+  const wrap = wrappingKey(rootKey);91+  try {92+    return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD)));93+  } catch {94+    // Wrong key or corrupt record: fail closed, never partial state.95+    return {};96+  } finally {97+    zeroKey(wrap);98+  }99+}100+101+async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise<void> {102+  const wrap = wrappingKey(rootKey);103+  try {104+    const sealed = await encrypt(wrap, JSON.stringify(map), AAD);105+    await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });106+  } finally {107+    zeroKey(wrap);108+  }109+}110+111+async function writeSessionCache(map: PairSecretMap): Promise<void> {112+  if (!sessionAllowed || !chrome.storage.session) return;113+  try {114+    await chrome.storage.session.set({ [SESSION_KEY]: map });115+  } catch {116+    // Session storage is quota-bound; losing locked sync for the session is117+    // preferable to failing the caller (same rule as the locked-sync spool).118+  }119+}120+121+async function readSessionCache(): Promise<PairSecretMap> {122+  if (!sessionAllowed || !chrome.storage.session) return {};123+  try {124+    const result = await chrome.storage.session.get(SESSION_KEY);125+    return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);126+  } catch {127+    return {};128+  }129+}130+131+async function clearSessionCache(): Promise<void> {132+  if (!chrome.storage.session) return;133+  try {134+    await chrome.storage.session.remove(SESSION_KEY);135+  } catch {136+    // Best effort: a stale operational copy is bounded by the browser session.137+  }138+}139+140+/**141+ * Resolve one device's rendezvous secret: trusted-session copy first, then142+ * the encrypted record when the caller holds the root key (unlocked).143+ * Returns null when neither path can serve — sync then stays dormant.144+ */145+export async function getPairSecret(146+  deviceId: string,147+  rootKeyHex: string | null,148+): Promise<string | null> {149+  const cached = await readSessionCache();150+  const hit = cached[deviceId];151+  if (isValidPairSecret(hit)) return hit;152+  if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null;153+  const rootKey = hexToBytes(rootKeyHex);154+  try {155+    const map = await readEncryptedMap(rootKey);156+    const secret = map[deviceId];157+    return isValidPairSecret(secret) ? secret : null;158+  } finally {159+    zeroKey(rootKey);160+  }161+}162+163+/**164+ * Store (or replace) one device's secret: encrypted record plus session copy.165+ * Pair/re-pair paths call this after upserting the public registry row.166+ */167+export async function setPairSecret(168+  deviceId: string,169+  secretHex: string,170+  rootKey: Uint8Array,171+): Promise<void> {172+  if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");173+  const map = await readEncryptedMap(rootKey);174+  map[deviceId] = secretHex;175+  await writeEncryptedMap(rootKey, map);176+  const cached = await readSessionCache();177+  cached[deviceId] = secretHex;178+  await writeSessionCache(cached);179+}180+181+/**182+ * Bring the encrypted record, the public registry, and the session cache183+ * into agreement: adopt valid legacy plaintext secrets, drop entries for184+ * devices no longer in the registry (unpair/revoke/repair), persist only185+ * after a verified read-back, and scrub the plaintext source last.186+ * Idempotent — safe to run on every real-vault unlock and to retry after187+ * an interruption at any step.188+ */189+export async function reconcilePairSecrets(190+  rootKey: Uint8Array,191+): Promise<{ migrated: number }> {192+  const devices = await listPairedDevices();193+  const registryIds = new Set(devices.map((d) => d.deviceId));194+  const legacy = new Map<string, string>();195+  for (const device of devices) {196+    if (isValidPairSecret(device.rendezvousSecretHex)) {197+      legacy.set(device.deviceId, device.rendezvousSecretHex);198+    }199+  }200+201+  const map = await readEncryptedMap(rootKey);202+  let migrated = 0;203+  for (const [deviceId, secret] of legacy) {204+    if (map[deviceId] !== secret) {205+      map[deviceId] = secret;206+      migrated += 1;207+    }208+  }209+  let pruned = false;210+  for (const deviceId of Object.keys(map)) {211+    if (!registryIds.has(deviceId)) {212+      delete map[deviceId];213+      pruned = true;214+    }215+  }216+217+  // Persist only when something actually changed; a fresh vault with no218+  // pairs writes nothing.219+  if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {220+    await writeEncryptedMap(rootKey, map);221+    // Crash safety: re-read and verify before touching the plaintext source.222+    const verified = await readEncryptedMap(rootKey);223+    for (const [deviceId, secret] of legacy) {224+      if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");225+    }226+    if (legacy.size > 0) {227+      await writePairedDevices(228+        devices.map((device) => {229+          if (!isValidPairSecret(device.rendezvousSecretHex)) return device;230+          const { rendezvousSecretHex: _dropped, ...pub } = device;231+          return pub;232+        }),233+      );234+    }235+  }236+237+  await writeSessionCache(map);238+  return { migrated };239+}240+241+/** Rotation drops all peers, so the secret map is cleared with the registry. */242+export async function clearPairSecrets(): Promise<void> {243+  try {244+    await chrome.storage.local.remove(RECORD_KEY);245+  } catch {246+    // Best effort alongside the registry clear it accompanies.247+  }248+  await clearSessionCache();249+}250+251+/**252+ * Drop the trusted-session operational copy without touching the encrypted253+ * record. Decoy unlocks call this: a duress session must never serve the254+ * real pair-secret map, and the next real unlock re-populates the copy.255+ */256+export async function dropSessionCache(): Promise<void> {257+  await clearSessionCache();258+}259+260+/**261+ * Forget one device immediately (unpair/revoke): the session copy is always262+ * removed; the encrypted entry is removed too when the caller holds the root263+ * key. Orphans left behind by locked-state removals are pruned by the next264+ * reconcile.265+ */266+export async function dropPairSecret(267+  deviceId: string,268+  rootKey: Uint8Array | null,269+): Promise<void> {270+  const cached = await readSessionCache();271+  if (cached[deviceId] !== undefined) {272+    delete cached[deviceId];273+    await writeSessionCache(cached);274+  }275+  if (!rootKey) return;276+  const map = await readEncryptedMap(rootKey);277+  if (map[deviceId] === undefined) return;278+  delete map[deviceId];279+  await writeEncryptedMap(rootKey, map);280+}

view file

src/background/paired-devices.ts+22 −1
@@ -53,6 +53,27 @@5353 }5454 5555 /**56+ * Durable registry write. PB-26-213: the reusable rendezvous secret must57+ * never reach this registry — it lives in the encrypted pair-secret record58+ * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a59+ * missed writer fails closed to a secretless row instead of plaintext disk.60+ */61+export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {62+  await chrome.storage.local.set({63+    [REGISTRY_KEY]: devices.map((device) => {64+      if (!isValidSecret(device.rendezvousSecretHex)) return device;65+      const scrubbed = { ...device };66+      delete scrubbed.rendezvousSecretHex;67+      return scrubbed;68+    }),69+  });70+}71+72+function isValidSecret(value: unknown): value is string {73+  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);74+}75+76+/**5677  * Add or update a device by `deviceId`. On re-pair the new pubkey5778  * silently replaces the old one; the deviceId is stable across the5879  * other device's reinstall.@@ -74,7 +95,7 @@7495   } else {7596     devices.push(stored);7697   }77-  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });98+  await writePairedDevices(devices);7899   // The device-list-changed sync trigger fires from the messages.ts handlers79100   // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they80101   // own the post-mutation contract and have the full payload available. Firing

view file

src/background/relay-adoption-bg.test.ts+27 −0
@@ -6,6 +6,7 @@66   getDeviceName: vi.fn(),77   getRootKeyHex: vi.fn(),88   upsert: vi.fn(),9+  setPairSecret: vi.fn(),910   pushes: [] as Array<{ type: string; payload: unknown }>,1011   triggerSync: vi.fn(),1112   createVault: vi.fn(),@@ -101,6 +102,12 @@101102   upsertPairedDevice: mocks.upsert,102103 }));103104 105+vi.mock("./pair-secrets", () => ({106+  setPairSecret: mocks.setPairSecret,107+  isValidPairSecret: (value: unknown): value is string =>108+    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),109+}));110+104111 vi.mock("./sync-trigger", () => ({105112   triggerSyncIfReachable: mocks.triggerSync,106113 }));@@ -153,6 +160,7 @@153160   mocks.getLocalIdentity.mockReset();154161   mocks.getRootKeyHex.mockReset();155162   mocks.upsert.mockReset();163+  mocks.setPairSecret.mockReset();156164   mocks.triggerSync.mockReset();157165   mocks.triggerSync.mockResolvedValue(undefined);158166   mocks.isProUser.mockReset();@@ -172,6 +180,7 @@172180   });173181   mocks.getRootKeyHex.mockReturnValue("c".repeat(64));174182   mocks.upsert.mockResolvedValue(undefined);183+  mocks.setPairSecret.mockResolvedValue(undefined);175184     const applyIncomingDeviceManifest = vi.fn(async () => undefined);176185     mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;177186     mocks.createSyncAdapter.mockReturnValue({@@ -281,6 +290,15 @@281290       deviceId: "android-receiver",282291       relayUrl: "wss://relay.peachpasswords.com",283292     }));293+    // PB-26-213: the registry row stays secretless; the secret bypasses it294+    // into the encrypted pair-secret record.295+    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;296+    expect(sourceRow.rendezvousSecretHex).toBeUndefined();297+    expect(mocks.setPairSecret).toHaveBeenCalledWith(298+      "android-receiver",299+      expect.stringMatching(/^[0-9a-f]{64}$/),300+      expect.any(Uint8Array),301+    );284302     expect(mocks.pushes).toContainEqual(expect.objectContaining({285303       type: "ADOPTION_SOURCE_RESULT",286304       payload: expect.objectContaining({ status: "complete" }),@@ -512,6 +530,8 @@512530     mocks.storageSet.mockResolvedValue(undefined);513531     mocks.upsert.mockReset();514532     mocks.upsert.mockResolvedValue(undefined);533+    mocks.setPairSecret.mockReset();534+    mocks.setPairSecret.mockResolvedValue(undefined);515535     mocks.triggerSync.mockReset();516536     mocks.triggerSync.mockResolvedValue(undefined);517537     mocks.openSocket.mockResolvedValue(socketWithFrames([]));@@ -701,6 +721,13 @@701721     expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({702722       deviceId: "phone-source-id",703723     }));724+    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;725+    expect(receiverRow.rendezvousSecretHex).toBeUndefined();726+    expect(mocks.setPairSecret).toHaveBeenCalledWith(727+      "phone-source-id",728+      expect.stringMatching(/^[0-9a-f]{64}$/),729+      expect.any(Uint8Array),730+    );704731     expect(mocks.storageSet).toHaveBeenCalledWith(705732       "peach_initial_sync_pending_device_id",706733       "phone-source-id",

view file

src/background/relay-adoption-bg.ts+26 −2
@@ -28,6 +28,7 @@2828   derivePairingSAS,2929   type PairingTransport,3030 } from "../shared/pairing-handshake";31+import type { PairedDevice } from "../shared/sync-types";3132 import {3233   bytesToHex,3334   deriveRendezvousSecret,@@ -41,6 +42,7 @@4142 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4243 import { storageRemove, storageSet } from "../shared/storage";4344 import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45+import { isValidPairSecret, setPairSecret } from "./pair-secrets";4446 import { createSyncVaultAdapter } from "./sync-vault-adapter";4547 import { triggerSyncIfReachable } from "./sync-trigger";4648 import { schedulePostAdoptionSync } from "./sync-alarm";@@ -270,7 +272,7 @@270272         derivePairingSAS(session.handshakeHash),271273         crypto.randomUUID(),272274       ),273-      upsertPairedDevice,275+      upsertPairedDevice: storeAdoptedDevice,274276     });275277     if (!device) {276278       push("ADOPTION_SOURCE_RESULT", {@@ -438,7 +440,7 @@438440       },439441       async upsertPairedDevice(paired) {440442         // Direct call for the same reason as applyLicense above.441-        await upsertPairedDevice(paired);443+        await storeAdoptedDevice(paired);442444       },443445       pairState: {444446         rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),@@ -638,6 +640,28 @@638640 639641 function decodeJson<T>(value: Uint8Array): T {640642   return JSON.parse(new TextDecoder().decode(value)) as T;643+}644+645+/**646+ * PB-26-213: an adopted device's secret bypasses the public registry into647+ * the encrypted pair-secret record. The registry row stays secretless; when648+ * the vault is somehow locked here the row still lands and the next unlock649+ * cannot recover this secret — re-pair is the fail-closed path.650+ */651+async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {652+  const secret = paired.rendezvousSecretHex;653+  const pub = { ...paired };654+  delete pub.rendezvousSecretHex;655+  await upsertPairedDevice(pub);656+  if (!isValidPairSecret(secret)) return;657+  const masterKeyHex = getRootKeyHex();658+  if (!masterKeyHex) return;659+  const masterKey = hexToBytes(masterKeyHex);660+  try {661+    await setPairSecret(paired.deviceId, secret, masterKey);662+  } finally {663+    masterKey.fill(0);664+  }641665 }642666 643667 function hexToBytes(hex: string): Uint8Array {

view file

src/background/sync-runner.test.ts+19 −0
@@ -200,6 +200,21 @@200200     return vault;201201   }202202 203+  // PB-26-213: device rows carry no reusable secrets; the runner resolves204+  // them from the encrypted pair-secret record / session copy.205+  async function seedPairSecret(secret = "11".repeat(32)): Promise<void> {206+    const vault = await import("./vault");207+    const rootHex = vault.getRootKeyHex();208+    if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets");209+    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));210+    try {211+      const { setPairSecret } = await import("./pair-secrets");212+      await setPairSecret("pixel-xyz", secret, root);213+    } finally {214+      root.fill(0);215+    }216+  }217+203218   // PB-26-104: locked sync has to keep working with the spool moved to204219   // chrome.storage.session. The previous fix removed the private key from the205220   // spool outright, which silently turned every locked round into VAULT_LOCKED.@@ -208,6 +223,7 @@208223     await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });209224     const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");210225     await refreshLockedSyncSpool();226+    await seedPairSecret();211227     vault.lock();212228     expect(vault.isUnlocked()).toBe(false);213229 @@ -270,6 +286,7 @@270286 271287   it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {272288     await bootVault();289+    await seedPairSecret();273290     const { session, peer } = inMemoryPair();274291     const openRelaySession = vi.fn(async () => session);275292     const deps: SyncRunnerDeps = {@@ -292,6 +309,7 @@292309 293310   it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {294311     await bootVault();312+    await seedPairSecret();295313     const { session, peer } = inMemoryPair();296314     const adapter: SyncVaultAdapter = {297315       ...emptyAdapter(),@@ -316,6 +334,7 @@316334 317335   it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {318336     await bootVault();337+    await seedPairSecret();319338     const { session, peer } = inMemoryPair();320339     const deps: SyncRunnerDeps = {321340       openRelaySession: vi.fn(async () => session),

view file

src/background/sync-runner.ts+11 −2
@@ -27,6 +27,7 @@2727 import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";2828 import { constantTimeEqualHex } from "../shared/constant-time";2929 import { getRootKeyHex, isUnlocked } from "./vault";30+import { getPairSecret } from "./pair-secrets";3031 import type { PairedDevice } from "../shared/sync-types";3132 import type { SyncRunResult } from "./sync-trigger";3233 import {@@ -71,12 +72,20 @@7172   if (!device.longTermPublicKeyHex) {7273     throw new Error("paired_device_missing_pubkey");7374   }74-  if (!device.rendezvousSecretHex) {75+  // PB-26-213: the secret resolves from the encrypted pair-secret record76+  // (unlocked) or the trusted-session copy (locked), never from the device77+  // row — the public registry carries no reusable secrets.78+  const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);79+  if (!secretHex) {80+    // Locked without a session copy: dormant until the next unlock, same as81+    // any other locked-vault sync attempt. Unlocked without a secret is a82+    // corrupt pairing that must surface loudly.83+    if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };7584     throw new Error("paired_device_missing_rendezvous_secret");7685   }7786   const expectedRemoteHex = device.longTermPublicKeyHex;7887   const reconnectIds = dialingRendezvousIds(79-    rendezvousSecretFromHex(device.rendezvousSecretHex),88+    rendezvousSecretFromHex(secretHex),8089     Date.now() / 1000,8190   );8291   // Sync identity derives from the stable root key, not the password-derived

view file

src/background/sync-vault-adapter.test.ts+25 −6
@@ -345,7 +345,7 @@345345   });346346 347347   it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {348-    const { adapter } = await setupVault();348+    const { vault, adapter } = await setupVault();349349     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");350350     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");351351     const {@@ -414,8 +414,14 @@414414       deviceName: "New Pixel",415415       trustSource: "vouched",416416       relayUrl: MESH_RELAY,417-      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),418417     });418+    // PB-26-213: the row carries no reusable secret; it resolves from the419+    // encrypted pair-secret record instead.420+    expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();421+    const { getPairSecret } = await import("./pair-secrets");422+    await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(423+      /^[0-9a-f]{64}$/,424+    );419425     const pending = await listPendingVouchedDevices();420426     expect(pending).toEqual([]);421427 @@ -781,6 +787,7 @@781787     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");782788     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");783789     const { listPendingVouchedDevices } = await import("./pending-vouched-devices");790+    const vault = await import("./vault");784791 785792     const mobileIssuerId = "android-issuer";786793     const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);@@ -814,13 +821,17 @@814821     expect(await findPairedDeviceById("mesh-peer")).toMatchObject({815822       trustSource: "vouched",816823       relayUrl: MESH_RELAY,817-      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),818824     });825+    expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();826+    const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");827+    await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(828+      /^[0-9a-f]{64}$/,829+    );819830     expect(await listPendingVouchedDevices()).toEqual([]);820831   });821832 822833   it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {823-    const { adapter } = await setupVault();834+    const { vault, adapter } = await setupVault();824835     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");825836     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");826837     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");@@ -860,8 +871,12 @@860871     expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({861872       trustSource: "vouched",862873       relayUrl: MESH_RELAY,863-      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),864874     });875+    expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();876+    const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");877+    await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(878+      /^[0-9a-f]{64}$/,879+    );865880     expect(await listPendingVouchedDevices()).toEqual([]);866881 867882     const extensionIssuerId = "extension-bad-sig";@@ -955,8 +970,12 @@955970     expect(await findPairedDeviceById("onward-peer")).toMatchObject({956971       trustSource: "vouched",957972       relayUrl: MESH_RELAY,958-      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),959973     });974+    expect((await findPairedDeviceById("onward-peer"))?.rendezvousSecretHex).toBeUndefined();975+    const { getPairSecret: getPairSecretStale } = await import("./pair-secrets");976+    await expect(getPairSecretStale("onward-peer", vault.getRootKeyHex())).resolves.toMatch(977+      /^[0-9a-f]{64}$/,978+    );960979     expect(await listPendingVouchedDevices()).toEqual([]);961980   });962981 

view file

src/background/sync-vault-adapter.ts+7 −5
@@ -57,6 +57,7 @@5757   removePairedDeviceById,5858   upsertPairedDevice,5959 } from "./paired-devices";60+import { setPairSecret } from "./pair-secrets";6061 import {6162   removePendingVouchedDevice,6263 } from "./pending-vouched-devices";@@ -422,11 +423,13 @@422423           longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),423424           signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),424425           wsPort: entry.wsPort,425-          rendezvousSecretHex: meshRoute.rendezvousSecretHex,426426           relayUrl: meshRoute.relayUrl,427427           pairedAt: entry.pairedAt,428428           trustSource: "vouched",429429         });430+        // PB-26-213: the secret bypasses the public registry into the431+        // encrypted pair-secret record.432+        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);430433         await removePendingVouchedDevice(entry.deviceId);431434         continue;432435       }@@ -477,16 +480,15 @@477480         pairedAt: existing.pairedAt ?? entry.pairedAt,478481         trustSource: existing.trustSource,479482         wsPort: existing.wsPort ?? entry.wsPort,480-        rendezvousSecretHex:481-          existing.trustSource === "vouched" && meshRoute482-            ? meshRoute.rendezvousSecretHex483-            : existing.rendezvousSecretHex,484483         relayUrl:485484           existing.trustSource === "vouched" && meshRoute486485             ? meshRoute.relayUrl487486             : existing.relayUrl,488487         lastSyncedAt: existing.lastSyncedAt,489488       });489+      if (existing.trustSource === "vouched" && meshRoute) {490+        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);491+      }490492     }491493   } finally {492494     masterKey.fill(0);

view file

src/background/vault.ts+27 −1
@@ -39,6 +39,12 @@3939   clearRecoveryConfirmPending,4040   setRecoveryConfirmPending,4141 } from "./recovery-pending";42+import {43+  clearPairSecrets,44+  dropSessionCache,45+  reconcilePairSecrets,46+  setSessionAllowed,47+} from "./pair-secrets";4248 4349 type EntryDraft = Omit<4450   VaultEntry,@@ -1465,6 +1471,10 @@14651471       return false;14661472     }14671473 1474+    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1475+    // record and refresh the trusted-session operational copy.1476+    await reconcilePairSecrets(rootKey);1477+14681478     await lockedSyncUnlockHook?.().catch((error) => {14691479       console.warn("[Peach sync] locked inbox drain failed:", error);14701480     });@@ -1483,6 +1493,8 @@14831493     await loadSettings();14841494     touchAutoLock();14851495     if (keyA) zeroKey(keyA);1496+    // PB-26-213: a duress session must never serve the real pair-secret map.1497+    await dropSessionCache();14861498     return true;14871499   }14881500 @@ -1519,6 +1531,7 @@15191531  * instead of trusting an unproven boundary.15201532  */15211533 export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1534+  setSessionAllowed(sessionEscrow);15221535   if (sessionEscrow) return;15231536   sessionEscrowAvailable = false;15241537   await clearSessionKey();@@ -1606,6 +1619,15 @@16061619   }16071620 16081621   if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1622+  if (freshRoot) {1623+    // PB-26-213: encrypted pair-secret record + session copy, before publish.1624+    await reconcilePairSecrets(freshRoot);1625+    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1626+  } else {1627+    // PB-26-213: a duress restore must never serve the real pair-secret map.1628+    await dropSessionCache();1629+    if (stale()) { zeroKey(key); return false; }1630+  }16091631   derivedKey = key;16101632   vaultMode = entry.mode;16111633   entries = payload.entries;@@ -1684,6 +1706,7 @@16841706       await lock();16851707       return { ok: false, error: "invalid_key" };16861708     }1709+    await reconcilePairSecrets(rootKey);16871710     return { ok: true, entries };16881711   } catch {16891712     return { ok: false, error: "invalid_key" };@@ -3197,8 +3220,11 @@31973220   if (rootKey) zeroKey(rootKey);31983221   rootKey = newRootKey;31993222 3200-  // 5. Old peers' identities derive from the dead root key — drop them all.3223+  // 5. Old peers' identities derive from the dead root key — drop them all,3224+  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3225+  // there is nothing to rewrap under the new key).32013226   await clearPairedDevices();3227+  await clearPairSecrets();32023228 32033229   // 6. Force re-presentation of the NEW recovery words on next popup view.32043230   await setRecoveryConfirmPending(); // same flag the create-vault flow uses

view file

src/shared/mesh-peer-role.ts+6 −3
@@ -6,6 +6,11 @@66 export function isRouteCapableMeshPeer(device: PairedDevice): boolean {77   // PB-26-006: fail closed on an unknown/absent trustSource — sibling trust88   // gates reject it, so routing must not treat it as direct/vouched.9+  //10+  // PB-26-213: the rendezvous secret is intentionally NOT part of this check.11+  // The public registry carries no reusable secrets; the secret resolves from12+  // the encrypted pair-secret record (or trusted session) at dial time, and13+  // peers without one are skipped there.914   const trusted =1015     device.trustSource === "direct" || device.trustSource === "vouched";1116   return (@@ -14,9 +19,7 @@1419       device.deviceType === "android" ||1520       device.deviceType === "ios") &&1621     typeof device.longTermPublicKeyHex === "string" &&17-    /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) &&18-    typeof device.rendezvousSecretHex === "string" &&19-    /^[0-9a-f]{64}$/.test(device.rendezvousSecretHex)22+    /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)2023   );2124 }2225 

view file