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
↓ peach-browser-da79fbcb.tar.gzatlas at this save
files changed17 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 −3manifest.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": {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",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",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);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,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);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+});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+}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. Firingsrc/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",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 {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),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-derivedsrc/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 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);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 usessrc/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