703d47477a7b
refactor: Remove retired pairing and LAN remnants (v4.10.34)
- time
- 2026-08-29 12:34 UTC · 1 month ago
- machine
- koh2
- parent
- 59d6ee714036
↓ peach-browser-703d4747.tar.gzatlas at this save
files changed11 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 −1manifest.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": {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",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",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 () => {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> {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-});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 }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-});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,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;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 () => {