703d47477a7b

refactor: Remove retired pairing and LAN remnants (v4.10.34)

- Keep only live pairing primitives and SAS derivation - Remove self-only handshake specs and obsolete last-known-IP cache - Preserve active relay adoption and cross-platform wsPort contracts

time
2026-08-29 12:34 UTC · 1 month ago
machine
koh2
parent
59d6ee714036
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/paired-devices.test.ts+1 −13src/background/paired-devices.ts+0 −29src/shared/pairing-handshake.test.ts+0 −192src/shared/pairing-handshake.ts+6 −352src/shared/pairing-handshake.vault-id.test.ts+0 −115src/shared/sync-transport.ts+2 −2src/shared/sync-types.ts+0 −4src/shared/vault-adopt-snapshot.test.ts+29 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.33",4+  "version": "4.10.34",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": "4.10.33",5+  "version": "4.10.34",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": "4.10.33",3+  "version": "4.10.34",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/paired-devices.test.ts+1 −13
@@ -1,11 +1,9 @@11 import { describe, expect, it, beforeEach } from "vitest";22 import type { PairedDevice } from "../shared/sync-types";33 import {4-  clearLastKnownIp,54   clearPairedDevices,65   findPairedDeviceById,76   findPairedDeviceByPubkey,8-  getLastKnownIp,97   getLastSyncAt,108   listPairedDevices,119   markLastSyncedAt,@@ -17,7 +15,6 @@1715   removePairedDeviceById,1816   renamePairedDevice,1917   revokeDeviceById,20-  setLastKnownIp,2118   setLastSyncAt,2219   setRelayUrlForAllPairedDevices,2320   upsertPairedDevice,@@ -125,12 +122,10 @@125122     expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();126123   });127124 128-  it("removePairedDeviceById drops the device and its IP cache", async () => {125+  it("removePairedDeviceById drops the device", async () => {129126     await upsertPairedDevice(SAMPLE);130-    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");131127     await removePairedDeviceById(SAMPLE.deviceId);132128     expect(await listPairedDevices()).toEqual([]);133-    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();134129   });135130 136131   it("clearPairedDevices wipes the whole registry", async () => {@@ -166,13 +161,6 @@166161       "wss://new-relay.example/base",167162       "wss://new-relay.example/base",168163     ]);169-  });170-171-  it("getLastKnownIp / setLastKnownIp round-trip", async () => {172-    await setLastKnownIp("ab".repeat(32), "10.0.0.5");173-    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");174-    await clearLastKnownIp("ab".repeat(32));175-    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();176164   });177165 178166   it("last sync timestamp accepts a legacy string value", async () => {

view file

src/background/paired-devices.ts+0 −29
@@ -5,10 +5,6 @@55 // `signingPublicKeyHex` are cryptographic keys; routing them through66 // Google's or Mozilla's sync back-end violates the local-only trust model.77 //8-// `lastKnownIp` is network-specific and also lives in `chrome.storage.local`,9-// keyed by `longTermPublicKeyHex` (not on the device record itself) so10-// network-specific addresses don't roam across profiles.11-//128 // `deviceId` is the canonical primary key (UUID v4, stable for the139 // lifetime of the installation). `longTermPublicKeyHex` is also unique1410 // per device but rotates if the user reinstalls before re-pairing, so we@@ -29,7 +25,6 @@2925 const REVOCATIONS_KEY = "peach_device_revocations_v1";3026 /** Bound the list so a hostile peer cannot grow storage without limit. */3127 const MAX_REVOCATIONS = 500;32-const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";3328 const LAST_SYNC_AT_KEY = "peach_last_sync_at";3429 const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3530 @@ -137,11 +132,6 @@137132   // device retains this device in its own registry. Noise KK rejection on138133   // the next connection attempt is the security boundary.139134   await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });140-  // Also clear the per-network IP cache for this device.141-  const device = devices.find((d) => d.deviceId === deviceId);142-  if (device?.longTermPublicKeyHex) {143-    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);144-  }145135   // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE146136   // handler — see upsertPairedDevice for why this lives there, not here.147137 }@@ -328,25 +318,6 @@328318   if (idx < 0) return;329319   devices[idx] = { ...devices[idx], lastSyncedAt: when };330320   await chrome.storage.local.set({ [REGISTRY_KEY]: devices });331-}332-333-export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {334-  try {335-    const key = LAST_IP_KEY_PREFIX + pubkeyHex;336-    const result = await chrome.storage.local.get(key);337-    const value = (result as Record<string, unknown>)[key];338-    return typeof value === "string" ? value : null;339-  } catch {340-    return null;341-  }342-}343-344-export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {345-  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });346-}347-348-export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {349-  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);350321 }351322 352323 export async function getLastSyncAt(): Promise<number | null> {

view file

src/shared/pairing-handshake.test.ts+0 −192
@@ -1,192 +0,0 @@1-import { describe, expect, it } from "vitest";2-import { generateKeyPair } from "@stablelib/x25519";3-import {4-  derivePairingSAS,5-  memoryTransportPair,6-  runPairingInitiator,7-  runPairingResponder,8-  type LocalIdentity,9-} from "./pairing-handshake";10-import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";11-12-const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));13-14-function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {15-  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);16-  return {17-    deviceId,18-    deviceName,19-    deviceType: type,20-    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),21-    signingPublicKeyHex: "b".repeat(64),22-  };23-}24-25-describe("pairing handshake (Noise NK + identity exchange)", () => {26-  it("exchanges long-term sync identities between initiator and responder", async () => {27-    // Responder owns the ephemeral that the initiator targets.28-    const respEphemeral = generateKeyPair();29-30-    const initIdent = identityFor("android-001", "June's Pixel 9", "android");31-    const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");32-33-    const [t1, t2] = memoryTransportPair();34-    const respPromise = runPairingResponder(35-      respEphemeral.secretKey,36-      respEphemeral.publicKey,37-      respIdent,38-      t2,39-    );40-    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);41-42-    const [respResult, initResult] = await Promise.all([respPromise, initPromise]);43-    const initLearned = initResult.device;44-    const respLearned = respResult.device;45-    expect(initLearned.deviceId).toBe("ext-001");46-    expect(initLearned.deviceName).toBe("Firefox on MacBook");47-    expect(initLearned.deviceType).toBe("extension");48-    expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);49-    expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);50-    expect(initLearned.trustSource).toBe("direct");51-52-    expect(respLearned.deviceId).toBe("android-001");53-    expect(respLearned.deviceName).toBe("June's Pixel 9");54-    expect(respLearned.deviceType).toBe("android");55-    expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);56-    expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);57-    expect(respLearned.trustSource).toBe("direct");58-59-    // Both ends derive the SAME 8-digit SAS from the shared handshake hash —60-    // this is what the user compares across devices.61-    expect(initResult.sas).toMatch(/^\d{8}$/);62-    expect(initResult.sas).toBe(respResult.sas);63-  });64-65-  it("carries a Pro license from the device that has one to the peer", async () => {66-    const respEphemeral = generateKeyPair();67-    // The initiator (e.g. an existing Pro phone) advertises its signed license.68-    const initIdent = { ...identityFor("android-pro", "Pro Pixel", "android"), license: "PPK-AAA-BBB" };69-    const respIdent = identityFor("ext-free", "Free Firefox", "extension"); // no license70-71-    const [t1, t2] = memoryTransportPair();72-    const respPromise = runPairingResponder(respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2);73-    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);74-    const [respResult, initResult] = await Promise.all([respPromise, initPromise]);75-76-    // The free responder learns the peer's license to activate; the initiator77-    // sees none (we sent it nothing). License is never written onto the device.78-    expect(respResult.peerLicense).toBe("PPK-AAA-BBB");79-    expect(initResult.peerLicense).toBeUndefined();80-    expect("license" in respResult.device).toBe(false);81-  });82-83-  it("rejects a malformed identity frame", async () => {84-    const respEphemeral = generateKeyPair();85-    const respIdent = identityFor("ext-002", "Firefox", "extension");86-    const [t1, t2] = memoryTransportPair();87-88-    // Initiator sends garbage instead of a PAIR_IDENTITY frame89-    const respPromise = runPairingResponder(90-      respEphemeral.secretKey,91-      respEphemeral.publicKey,92-      respIdent,93-      t2,94-    );95-    const evilInitiator = (async () => {96-      const { noiseNKInitiator } = await import("./noise-js");97-      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);98-      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY99-    })();100-101-    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);102-    await evilInitiator.catch(() => undefined);103-  });104-105-  it("rejects an identity with a malformed long_term_public_key_hex", async () => {106-    const respEphemeral = generateKeyPair();107-    const respIdent = identityFor("ext-003", "Firefox", "extension");108-    const [t1, t2] = memoryTransportPair();109-110-    const respPromise = runPairingResponder(111-      respEphemeral.secretKey,112-      respEphemeral.publicKey,113-      respIdent,114-      t2,115-    );116-    const evilInitiator = (async () => {117-      const { noiseNKInitiator } = await import("./noise-js");118-      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);119-      const frame = JSON.stringify({120-        type: "PAIR_IDENTITY",121-        device_id: "evil",122-        device_name: "evil",123-        device_type: "android",124-        long_term_public_key_hex: "deadbeef", // too short125-      });126-      await t1.send(session.encrypt(new TextEncoder().encode(frame)));127-    })();128-129-    await expect(respPromise).rejects.toThrow(/32-byte hex/);130-    await evilInitiator.catch(() => undefined);131-  });132-133-  it("rejects an identity with a malformed signing_public_key_hex", async () => {134-    const respEphemeral = generateKeyPair();135-    const respIdent = identityFor("ext-004", "Firefox", "extension");136-    const [t1, t2] = memoryTransportPair();137-138-    const respPromise = runPairingResponder(139-      respEphemeral.secretKey,140-      respEphemeral.publicKey,141-      respIdent,142-      t2,143-    );144-    const evilInitiator = (async () => {145-      const { noiseNKInitiator } = await import("./noise-js");146-      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);147-      const frame = JSON.stringify({148-        type: "PAIR_IDENTITY",149-        device_id: "evil",150-        device_name: "evil",151-        device_type: "android",152-        long_term_public_key_hex: "a".repeat(64),153-        signing_public_key_hex: "deadbeef",154-      });155-      await t1.send(session.encrypt(new TextEncoder().encode(frame)));156-    })();157-158-    await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);159-    await evilInitiator.catch(() => undefined);160-  });161-});162-163-describe("derivePairingSAS (out-of-band MITM-detection code)", () => {164-  it("returns a deterministic 8-digit code", () => {165-    const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));166-    const sas = derivePairingSAS(h);167-    expect(sas).toMatch(/^\d{8}$/);168-    expect(derivePairingSAS(h)).toBe(sas);169-  });170-171-  it("both peers derive the same SAS from the shared Noise handshake hash", () => {172-    // After the handshake both ends hold the identical handshakeHash, so both173-    // independently compute the same comparison code.174-    const shared = crypto.getRandomValues(new Uint8Array(32));175-    expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));176-  });177-178-  it("differs for different handshake hashes", () => {179-    expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0))).not.toBe(180-      derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)),181-    );182-  });183-184-  it("pins the cross-platform big-endian 64-bit vector", () => {185-    expect(derivePairingSAS(Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7))).toBe("52306183");186-    expect(derivePairingSAS(new Uint8Array(8).fill(0xff))).toBe("09551615");187-  });188-189-  it("rejects undersized input", () => {190-    expect(() => derivePairingSAS(new Uint8Array(7))).toThrow(/too short/);191-  });192-});

view file at the parent

src/shared/pairing-handshake.ts+6 −352
@@ -1,59 +1,12 @@1-// Pairing handshake — Noise NK, the SAS, and the PAIR_IDENTITY frame.2-//3-// STATUS: the audio-pairing flow this module was written for is gone. What production still4-// uses from here is `derivePairingSAS` and the `LocalIdentity` /5-// `PairingTransport` types; the live dial-and-handshake path is6-// `shared/pairing-dial.ts`, and the live PAIR_IDENTITY exchange is7-// `background/code-pairing.ts`.8-//9-// The `runPairing*` helpers below are exercised only by this module's own10-// specs. Do NOT reach for the RESPONDER ones in new code: they assume this11-// device can accept an inbound connection, which a browser extension cannot12-// do. The browser is always the dialler, always the NK initiator.13-//14-// The shape of the exchange, which the live path still follows:15-//16-//   1. Both sides instantiate Noise NK over a caller-supplied transport. The17-//      responder's "static" is an ephemeral it published out of band — today18-//      that is the `ephemeralPublicKeyHex` it returns in PEACH_HELLO_ACK,19-//      which is why the SAS below carries the real authentication weight.20-//21-//   2. Over the resulting Noise transport, each side sends ONE JSON frame with22-//      its `device_id`, `device_name`, `device_type`,23-//      `long_term_public_key_hex`, and device-manifest signing key. Both sides24-//      persist the counterpart in the paired-device registry.25-//26-//   3. This first-pair NK session is discarded. Future WebSocket reconnects27-//      (`shared/sync-transport.ts`) use standard Noise KK with both long-term28-//      static keys from paired local state, followed by a bound29-//      PAIR_IDENTITY exchange. KK failure never falls back to NK.1+// Shared pairing primitives used by the live relay-adoption path.302 31-import { noiseNKInitiator, noiseNKResponder } from "./noise-js";32-import type { DeviceType, PairedDevice } from "./sync-types";33-import {34-  bytesToHex,35-  deriveRendezvousSecret,36-  normalizeRelayUrl,37-} from "./rendezvous";3+import type { DeviceType } from "./sync-types";384 395 export interface PairingTransport {406   send(msg: Uint8Array): Promise<void>;417   receive(): Promise<Uint8Array>;428 }439 44-/** Result of a pairing handshake: the peer's identity plus the SAS the user45- *  must confirm matches the peer device before the pair is persisted. */46-export interface PairingHandshakeResult {47-  device: PairedDevice;48-  sas: string;49-  /**50-   * The peer's signed Pro license blob, if it advertised one. The caller51-   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro52-   * device can grant Pro to the other when pairing. Never persisted as-is.53-   */54-  peerLicense?: string;55-}56-5710 export interface LocalIdentity {5811   deviceId: string;5912   deviceName: string;@@ -62,321 +15,22 @@6215   signingPublicKeyHex?: string;6316   /** Server-only field — Android/iOS sync hosts announce their port. */6417   wsPort?: number;65-  /**66-   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the67-   * outbound PAIR_IDENTITY so a native peer can record which vault it68-   * paired to. Optional: the caller omits it when the vault is locked (no69-   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE70-   * at sync-identity.ts:62 (PCH-02-006).71-   */18+  /** Stable per-vault id, omitted while the vault is locked. */7219   vaultId?: string;73-  /**74-   * This device's signed Pro license blob (`PPK-…`), included only when this75-   * device is Pro. The peer activates it (signature-verified) after the SAS is76-   * confirmed, so pairing carries Pro to a free device. Either direction.77-   */20+  /** Signed Pro license blob advertised after SAS confirmation. */7821   license?: string;7922   relayUrl?: string;8023 }8124 82-interface PairingFrame {83-  type: "PAIR_IDENTITY";84-  device_id: string;85-  device_name: string;86-  device_type: DeviceType;87-  long_term_public_key_hex: string;88-  signing_public_key_hex?: string;89-  ws_port?: number;90-  // Optional: present only when the sender knows its vault id. peach-sync91-  // treats this as optional (pairing.zig:54); mobile peers may omit it.92-  vault_id?: string;93-  // Optional signed Pro license blob — additive; peers that don't understand94-  // it ignore it. Activated by the receiver only after SAS confirmation.95-  license?: string;96-  relay_url?: string;97-}98-99-export interface PostNoiseFrame {100-  sas: string;101-  rendezvousSecretHex: string;102-  frame: unknown;103-  channel: PairingTransport;104-}105-106-export interface PostNoiseChannel {107-  sas: string;108-  rendezvousSecretHex: string;109-  channel: PairingTransport;110-}111-112-function encodeFrame(frame: PairingFrame): Uint8Array {113-  return new TextEncoder().encode(JSON.stringify(frame));114-}115-116-function decodeParsedFrame(parsed: unknown): PairingFrame {117-  const value = parsed as Record<string, unknown> | null;118-  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");119-  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");120-  if (typeof value.device_name !== "string") throw new Error("device_name missing");121-  if (typeof value.device_type !== "string") throw new Error("device_type missing");122-  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");123-  // Long-term pubkey is a hex-encoded 32-byte X25519 key.124-  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {125-    throw new Error("long_term_public_key_hex is not 32-byte hex");126-  }127-  if (128-    value.signing_public_key_hex !== undefined &&129-    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))130-  ) {131-    throw new Error("signing_public_key_hex is not 32-byte hex");132-  }133-  if (value.relay_url !== undefined) {134-    if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");135-    value.relay_url = normalizeRelayUrl(value.relay_url);136-  }137-  return value as unknown as PairingFrame;138-}139-140-/**141- * Run the pairing handshake from the INITIATOR side.142- *143- * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, learned out144- * of band. The Noise NK transport message 1 encrypts to that key; the145- * responder must hold the matching private key.146- */147-export async function runPairingInitiator(148-  remoteEphemeralPublicKey: Uint8Array,149-  ourIdentity: LocalIdentity,150-  transport: PairingTransport,151-): Promise<PairingHandshakeResult> {152-  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);153-154-  // Send our identity, then receive theirs.155-  await postNoise.channel.send(encodeFrame({156-    type: "PAIR_IDENTITY",157-    device_id: ourIdentity.deviceId,158-    device_name: ourIdentity.deviceName,159-    device_type: ourIdentity.deviceType,160-    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,161-    signing_public_key_hex: ourIdentity.signingPublicKeyHex,162-    ws_port: ourIdentity.wsPort,163-    vault_id: ourIdentity.vaultId,164-    license: ourIdentity.license,165-    relay_url: ourIdentity.relayUrl,166-  }));167-168-  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));169-  // The SAS is derived from the shared Noise handshake hash; the caller MUST170-  // have the user confirm it matches the peer's before persisting the device.171-  return {172-    device: {173-      ...frameToPairedDevice(theirFrame),174-      rendezvousSecretHex: postNoise.rendezvousSecretHex,175-    },176-    sas: postNoise.sas,177-    peerLicense: peerLicenseOf(theirFrame),178-  };179-}180-181-export async function runPairingInitiatorOpenChannel(182-  remoteEphemeralPublicKey: Uint8Array,183-  transport: PairingTransport,184-): Promise<PostNoiseChannel> {185-  const session = await noiseNKInitiator(186-    remoteEphemeralPublicKey,187-    transport.send,188-    transport.receive,189-  );190-191-  const channel: PairingTransport = {192-    async send(msg) {193-      await transport.send(session.encrypt(msg));194-    },195-    async receive() {196-      return session.decrypt(await transport.receive());197-    },198-  };199-  return {200-    sas: derivePairingSAS(session.handshakeHash),201-    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),202-    channel,203-  };204-}205-206-// ───────────────────────────────────────────────────────────────────────────207-// SECURITY — TRUST MODEL: first pairing uses NK because the responder key is208-// known from the one-time code but the initiator identity is not paired yet.209-// Immediately after NK, Android sends a transcript-bound challenge and the210-// browser proves possession of the private half of the X25519 key it will place211-// in PAIR_IDENTITY. Android constant-time binds that proven key to the frame;212-// browser likewise requires Android's identity key to equal the NK responder213-// static. This is structural authentication, not a claim carried by JSON.214-//215-// The user-compared SAS remains mandatory defense in depth. Both sides derive216-// the same value from the handshake hash, display it, and persist nothing until217-// the user confirms. Android's live CodePairingServer blocks on the same gate.218-// Reconnect is a separate local-state-selected Noise KK path and never falls219-// back to NK.220-// ───────────────────────────────────────────────────────────────────────────221-222-/**223- * Derive an 8-digit Short Authentication String from a Noise handshake hash.224- * Both pairing peers compute the SAME value because the Noise `handshakeHash`225- * is shared after the handshake. Showing it on both devices and having the226- * user confirm they match authenticates the channel out-of-band, detecting a227- * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).228- */25+/** Derive the cross-platform 8-digit SAS from a Noise handshake hash. */22926 export function derivePairingSAS(handshakeHash: Uint8Array): string {23027   if (handshakeHash.length < 8) {23128     throw new Error("handshake hash too short for SAS");23229   }233-  // Use 64 PRF-output bits so modulo bias at the 1e8 boundary is negligible.234-  // The identical big-endian rule is pinned by Android cross-platform KATs.30+  // The big-endian rule is pinned by the Android cross-platform KATs.23531   let value = 0n;23632   for (const byte of handshakeHash.subarray(0, 8)) {23733     value = (value << 8n) | BigInt(byte);23834   }23935   return String(value % 100_000_000n).padStart(8, "0");240-}241-242-/**243- * Run the pairing handshake from the RESPONDER side.244- *245- * NOT REACHABLE FROM A BROWSER — responding means accepting an inbound246- * connection. Retained for the protocol definition and its specs only.247- */248-export async function runPairingResponder(249-  ourEphemeralPrivateKey: Uint8Array,250-  ourEphemeralPublicKey: Uint8Array,251-  ourIdentity: LocalIdentity,252-  transport: PairingTransport,253-): Promise<PairingHandshakeResult> {254-  const postNoise = await runPairingResponderOpenFrame(255-    ourEphemeralPrivateKey,256-    ourEphemeralPublicKey,257-    transport,258-  );259-  return handlePairIdentityFrame(260-    postNoise.frame,261-    postNoise.channel,262-    ourIdentity,263-    postNoise.sas,264-    postNoise.rendezvousSecretHex,265-  );266-}267-268-export async function runPairingResponderOpenFrame(269-  ourEphemeralPrivateKey: Uint8Array,270-  ourEphemeralPublicKey: Uint8Array,271-  transport: PairingTransport,272-): Promise<PostNoiseFrame> {273-  const session = await noiseNKResponder(274-    ourEphemeralPrivateKey,275-    ourEphemeralPublicKey,276-    transport.send,277-    transport.receive,278-  );279-280-  const channel: PairingTransport = {281-    async send(msg) {282-      await transport.send(session.encrypt(msg));283-    },284-    async receive() {285-      return session.decrypt(await transport.receive());286-    },287-  };288-  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));289-  return {290-    sas: derivePairingSAS(session.handshakeHash),291-    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),292-    frame: theirFrame,293-    channel,294-  };295-}296-297-export async function handlePairIdentityFrame(298-  frame: unknown,299-  channel: PairingTransport,300-  ourIdentity: LocalIdentity,301-  sas: string,302-  rendezvousSecretHex: string,303-): Promise<PairingHandshakeResult> {304-  const theirFrame = decodeParsedFrame(frame);305-  const ourFrame = encodeFrame({306-    type: "PAIR_IDENTITY",307-    device_id: ourIdentity.deviceId,308-    device_name: ourIdentity.deviceName,309-    device_type: ourIdentity.deviceType,310-    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,311-    signing_public_key_hex: ourIdentity.signingPublicKeyHex,312-    ws_port: ourIdentity.wsPort,313-    vault_id: ourIdentity.vaultId,314-    license: ourIdentity.license,315-    relay_url: ourIdentity.relayUrl,316-  });317-  await channel.send(ourFrame);318-319-  // SAS derived from the shared handshake hash — caller must have the user320-  // confirm it matches the peer's code before persisting (defeats the321-  // initiator-impersonation / MITM attack the NK handshake alone allows).322-  return {323-    device: {324-      ...frameToPairedDevice(theirFrame),325-      rendezvousSecretHex,326-    },327-    sas,328-    peerLicense: peerLicenseOf(theirFrame),329-  };330-}331-332-/** Pull a plausible license string off a peer frame (signature is verified333- *  later, on activation). Anything non-string is ignored. */334-function peerLicenseOf(frame: PairingFrame): string | undefined {335-  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;336-}337-338-function frameToPairedDevice(frame: PairingFrame): PairedDevice {339-  return {340-    deviceId: frame.device_id,341-    deviceName: frame.device_name,342-    deviceType: frame.device_type,343-    longTermPublicKeyHex: frame.long_term_public_key_hex,344-    signingPublicKeyHex: frame.signing_public_key_hex,345-    trustSource: "direct",346-    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,347-    relayUrl: frame.relay_url,348-    pairedAt: Date.now(),349-  };350-}351-352-/**353- * Test-helper: an in-memory pair of transports that connect to each other.354- * Use one half for the initiator and the other for the responder.355- */356-export function memoryTransportPair(): [PairingTransport, PairingTransport] {357-  const a: Uint8Array[] = [];358-  const b: Uint8Array[] = [];359-  const aReaders: Array<(b: Uint8Array) => void> = [];360-  const bReaders: Array<(b: Uint8Array) => void> = [];361-362-  function makeTransport(363-    inbox: Uint8Array[],364-    outbox: Uint8Array[],365-    inboxReaders: Array<(b: Uint8Array) => void>,366-    outboxReaders: Array<(b: Uint8Array) => void>,367-  ): PairingTransport {368-    return {369-      async send(msg: Uint8Array) {370-        const r = outboxReaders.shift();371-        if (r) r(msg);372-        else outbox.push(msg);373-      },374-      receive() {375-        const msg = inbox.shift();376-        if (msg) return Promise.resolve(msg);377-        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));378-      },379-    };380-  }381-  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];38236 }

view file

src/shared/pairing-handshake.vault-id.test.ts+0 −115
@@ -1,115 +0,0 @@1-// Change 1 — vault_id in the PAIR_IDENTITY frame.2-//3-// Relocated from drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts.4-// Asserts the `vaultId` field added to LocalIdentity is emitted into the5-// outbound PAIR_IDENTITY frame, and is omitted entirely when absent (so mobile6-// mobile peers and a locked vault stay backwards-compatible).7-//8-// Strategy: stand in as the NK responder ourselves and decrypt the first9-// transport frame the initiator sends — the browser is the NK initiator in the10-// browser-to-phone code-pairing flow, so this exercises the exact11-// outbound path.12-13-import { describe, expect, it } from "vitest";14-import { generateKeyPair } from "@stablelib/x25519";15-import {16-  memoryTransportPair,17-  runPairingInitiator,18-  type LocalIdentity,19-} from "./pairing-handshake";20-import { noiseNKResponder } from "./noise-js";21-import {22-  computeVaultId,23-  deriveSyncIdentityKeypair,24-  pubkeyToHex,25-} from "./sync-identity";26-27-const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));28-29-async function identityFor(30-  deviceId: string,31-  deviceName: string,32-  type: LocalIdentity["deviceType"],33-  opts: { withVaultId?: boolean } = {},34-): Promise<LocalIdentity> {35-  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);36-  return {37-    deviceId,38-    deviceName,39-    deviceType: type,40-    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),41-    signingPublicKeyHex: "b".repeat(64),42-    vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined,43-  };44-}45-46-/**47- * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the48- * responder ourselves and decrypting the first transport frame.49- */50-async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {51-  const respEphemeral = generateKeyPair();52-  const [t1, t2] = memoryTransportPair();53-54-  let captured: Record<string, unknown> | undefined;55-56-  const responder = (async () => {57-    const session = await noiseNKResponder(58-      respEphemeral.secretKey,59-      respEphemeral.publicKey,60-      t2.send,61-      t2.receive,62-    );63-    // Initiator sends its PAIR_IDENTITY first.64-    const cipher = await t2.receive();65-    const plain = session.decrypt(cipher);66-    captured = JSON.parse(new TextDecoder().decode(plain));67-    // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.68-    const back = JSON.stringify({69-      type: "PAIR_IDENTITY",70-      device_id: "android-001",71-      device_name: "Pixel 8",72-      device_type: "android",73-      long_term_public_key_hex: "a".repeat(64),74-      ws_port: 49142,75-    });76-    await t2.send(session.encrypt(new TextEncoder().encode(back)));77-  })();78-79-  await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);80-  await responder;81-  if (!captured) throw new Error("did not capture initiator frame");82-  return captured;83-}84-85-describe("PAIR_IDENTITY vault_id (Change 1)", () => {86-  it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => {87-    const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true });88-    const frame = await captureInitiatorFrame(initIdent);89-90-    expect(frame.type).toBe("PAIR_IDENTITY");91-    expect(frame).toHaveProperty("vault_id");92-    expect(typeof frame.vault_id).toBe("string");93-  });94-95-  it("vault_id is a non-empty 64-char lowercase hex string", async () => {96-    const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true });97-    const frame = await captureInitiatorFrame(initIdent);98-99-    const vaultId = frame.vault_id as string;100-    expect(vaultId.length).toBeGreaterThan(0);101-    // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars.102-    expect(vaultId).toMatch(/^[0-9a-f]{64}$/);103-    // Must equal the canonical computeVaultId output for the same master key.104-    expect(vaultId).toBe(await computeVaultId(MASTER));105-  });106-107-  it("omits vault_id entirely when the identity has no vaultId (locked vault / mobile peer)", async () => {108-    const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });109-    const frame = await captureInitiatorFrame(initIdent);110-111-    // JSON.stringify drops undefined keys — backwards-compatible with peers112-    // (and peach-sync) that treat vault_id as optional.113-    expect(frame).not.toHaveProperty("vault_id");114-  });115-});

view file at the parent

src/shared/sync-transport.ts+2 −2
@@ -106,8 +106,8 @@106106 }107107 108108 /**109- * Relay reconnect preserves the exact LAN wire protocol; only the WebSocket110- * target changes. The URL builder enforces credential-free `wss://`.109+ * Relay reconnect preserves the existing Noise KK wire protocol. The URL110+ * builder enforces credential-free `wss://`.111111  */112112 export async function openRelaySyncSession(113113   relayUrl: string,

view file

src/shared/sync-types.ts+0 −4
@@ -35,10 +35,6 @@3535  * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket3636  * reconnect runs Noise KK with both local static keys, followed by a3737  * PAIR_IDENTITY exchange bound to the authenticated keys.38- *39- * `lastKnownIp` is intentionally NOT stored here — it's per-network and40- * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See41- * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.4238  */4339 export interface PairedDevice {4440   deviceId: string;

view file

src/shared/vault-adopt-snapshot.test.ts+29 −1
@@ -9,7 +9,35 @@99   sendVaultSnapshot,1010   type AdoptionReceiverDeps,1111 } from "./vault-adopt";12-import { memoryTransportPair, type PairingTransport } from "./pairing-handshake";12+import type { PairingTransport } from "./pairing-handshake";13+14+function memoryTransportPair(): [PairingTransport, PairingTransport] {15+  const a: Uint8Array[] = [];16+  const b: Uint8Array[] = [];17+  const aReaders: Array<(message: Uint8Array) => void> = [];18+  const bReaders: Array<(message: Uint8Array) => void> = [];19+20+  const transport = (21+    inbox: Uint8Array[],22+    outbox: Uint8Array[],23+    inboxReaders: Array<(message: Uint8Array) => void>,24+    outboxReaders: Array<(message: Uint8Array) => void>,25+  ): PairingTransport => ({26+    async send(message) {27+      const reader = outboxReaders.shift();28+      if (reader) reader(message);29+      else outbox.push(message);30+    },31+    receive() {32+      const message = inbox.shift();33+      return message34+        ? Promise.resolve(message)35+        : new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));36+    },37+  });38+39+  return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)];40+}1341 1442 describe("inline vault snapshot", () => {1543   it("round-trips an empty vault (0 entries)", async () => {

view file