5845cedc6119

feat: route adoption grant requests after SAS consent (v4.0.18)

time
2026-06-19 20:14 UTC · 4 months ago
machine
koh2
agent
gpt-5
session
vault adoption integration
parent
bae5bc004f31
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+21 −0src/popup/chirp-pair.adopt-source.test.ts+145 −0src/popup/chirp-pair.ts+96 −19src/popup/views/AdoptVaultView.test.tsx+13 −0src/popup/views/ChirpSync.tsx+52 −1src/shared/chirp-adopt.ts+7 −5src/shared/chirp-handshake.ts+57 −15src/shared/types.ts+1 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.17",5+  "version": "4.0.18",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.17",4+  "version": "4.0.18",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/messages.ts+21 −0
@@ -12,6 +12,7 @@1212 import { copyToClipboard, getEffectiveClearSeconds } from "./clipboard";1313 import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";1414 import { extractDomain } from "../shared/domain";15+import { computeVaultId } from "../shared/sync-identity";1516 import {1617   getPendingVouchedDevice,1718   listPendingVouchedDevices,@@ -1354,6 +1355,26 @@13541355           return { ok: true, data: identity };13551356         } finally {13561357           masterKey.fill(0);1358+        }1359+      }1360+1361+      case "CHIRP_GET_ADOPTION_MATERIAL": {1362+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1363+        const rootKeyHex = vault.getRootKeyHex();1364+        if (!rootKeyHex) return { ok: false, error: "vault_locked" };1365+        const rootKeyBytes = unhexBytes(rootKeyHex);1366+        try {1367+          const data: { rootKeyHex: string; vaultId: string; license?: string } = {1368+            rootKeyHex,1369+            vaultId: await computeVaultId(rootKeyBytes),1370+          };1371+          if (await isProUser()) {1372+            const license = await getLicenseKey();1373+            if (license) data.license = license;1374+          }1375+          return { ok: true, data };1376+        } finally {1377+          rootKeyBytes.fill(0);13571378         }13581379       }13591380 

view file

src/popup/chirp-pair.adopt-source.test.ts+145 −0
@@ -0,0 +1,145 @@1+import { describe, expect, it, vi } from "vitest";2+import { computeVaultId } from "../shared/sync-identity";3+import { routeSourcePostSASFrame } from "./chirp-pair";4+import type { LocalIdentity, PairingTransport } from "../shared/chirp-handshake";5+6+const localIdentity: LocalIdentity = {7+  deviceId: "source-device",8+  deviceName: "Source",9+  deviceType: "extension",10+  longTermPublicKeyHex: "ab".repeat(32),11+  signingPublicKeyHex: "cd".repeat(32),12+};13+14+function channel(): PairingTransport & { sent: Uint8Array[]; close: ReturnType<typeof vi.fn> } {15+  const sent: Uint8Array[] = [];16+  return {17+    sent,18+    close: vi.fn(async () => {}),19+    async send(msg) { sent.push(msg); },20+    async receive() {21+      return new TextEncoder().encode(JSON.stringify({22+        type: "ADOPT_IDENTITY",23+        device_id: "receiver-device",24+        device_name: "Receiver",25+        device_type: "extension",26+        long_term_public_key_hex: "ef".repeat(32),27+      }));28+    },29+  };30+}31+32+describe("source-side ADOPT_REQUEST routing", () => {33+  it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation and consent", async () => {34+    const rootKeyHex = "77".repeat(32);35+    const ch = channel();36+    const completePairing = vi.fn();37+    const result = await routeSourcePostSASFrame(38+      {39+        sas: "895245",40+        frame: {41+          type: "ADOPT_REQUEST",42+          device_id: "receiver-ephemeral",43+          device_name: "Receiver",44+          device_type: "extension",45+        },46+        channel: ch,47+      },48+      localIdentity,49+      {50+        confirmSAS: vi.fn(async () => true),51+        completePairing,52+        grantAdoptionConsent: vi.fn(async () => true),53+        fetchAdoptionMaterial: vi.fn(async () => ({54+          rootKeyHex,55+          vaultId: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),56+          license: "PPK-test",57+        })),58+        onProgress: vi.fn(),59+      },60+    );61+62+    const grant = JSON.parse(new TextDecoder().decode(ch.sent[0]));63+    expect(result).toEqual(expect.objectContaining({ deviceId: "receiver-device" }));64+    expect(grant).toMatchObject({65+      type: "ADOPT_GRANT",66+      root_key_hex: rootKeyHex,67+      vault_id: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),68+      license: "PPK-test",69+    });70+    expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));71+  });72+73+  it("T-A27: routes PAIR_IDENTITY to the existing pairing path", async () => {74+    const ch = channel();75+    const completePairing = vi.fn();76+    const result = await routeSourcePostSASFrame(77+      {78+        sas: "895245",79+        frame: {80+          type: "PAIR_IDENTITY",81+          device_id: "phone",82+          device_name: "Phone",83+          device_type: "android",84+          long_term_public_key_hex: "aa".repeat(32),85+        },86+        channel: ch,87+      },88+      localIdentity,89+      {90+        confirmSAS: vi.fn(async () => true),91+        completePairing,92+        onProgress: vi.fn(),93+      },94+    );95+96+    const response = JSON.parse(new TextDecoder().decode(ch.sent[0]));97+    expect(response.type).toBe("PAIR_IDENTITY");98+    expect(result).toEqual(expect.objectContaining({ deviceId: "phone" }));99+    expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "phone" }));100+  });101+102+  it("T-A28: unknown frame closes the channel gracefully", async () => {103+    const ch = channel();104+    await expect(routeSourcePostSASFrame(105+      { sas: "895245", frame: { type: "WHAT_IS_THIS" }, channel: ch },106+      localIdentity,107+      {108+        confirmSAS: vi.fn(async () => true),109+        completePairing: vi.fn(),110+        onProgress: vi.fn(),111+      },112+    )).rejects.toThrow("unexpected_pairing_frame");113+114+    expect(ch.close).toHaveBeenCalledTimes(1);115+  });116+117+  it("T-A29: source root key bytes are zeroed after grantAdoption regardless of outcome", async () => {118+    const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");119+    await routeSourcePostSASFrame(120+      {121+        sas: "895245",122+        frame: {123+          type: "ADOPT_REQUEST",124+          device_id: "receiver-ephemeral",125+          device_name: "Receiver",126+          device_type: "extension",127+        },128+        channel: channel(),129+      },130+      localIdentity,131+      {132+        confirmSAS: vi.fn(async () => true),133+        completePairing: vi.fn(),134+        grantAdoptionConsent: vi.fn(async () => true),135+        fetchAdoptionMaterial: vi.fn(async () => ({136+          rootKeyHex: "88".repeat(32),137+          vaultId: await computeVaultId(Uint8Array.from(Buffer.from("88".repeat(32), "hex"))),138+        })),139+        onProgress: vi.fn(),140+      },141+    );142+143+    expect(fillSpy).toHaveBeenCalledWith(0);144+  });145+});

view file

src/popup/chirp-pair.ts+96 −19
@@ -25,11 +25,15 @@2525   runChirpExchange,2626 } from "../shared/chirp-pairing";2727 import {28+  handlePairIdentityFrame,2829   runPairingInitiator,29-  runPairingResponder,30+  runPairingResponderOpenFrame,3031   type LocalIdentity,3132   type PairingHandshakeResult,33+  type PostNoiseFrame,3234 } from "../shared/chirp-handshake";35+import { computeVaultId } from "../shared/sync-identity";36+import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";3337 import {3438   DEVICE_TYPE_EXTENSION,3539   DEVICE_TYPE_ANDROID,@@ -77,6 +81,10 @@7781    * Pro device grants Pro to the other when pairing (either direction).7882    */7983   activatePeerLicense?(license: string): Promise<void>;84+  /** Source-device adoption consent gate, shown only after SAS confirmation. */85+  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;86+  /** Source-device grant material, fetched only after SAS and consent. */87+  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;8088   /**8189    * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.8290    * Used to scan the LAN for the initiator's pairing-port WebSocket server.@@ -173,6 +181,75 @@173181   return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);174182 }175183 184+export interface SourcePostSASDeps {185+  confirmSAS(sas: string): Promise<boolean>;186+  completePairing(device: PairedDevice): Promise<void>;187+  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;188+  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;189+  activatePeerLicense?(license: string): Promise<void>;190+  onProgress(p: PairingProgress): void;191+}192+193+export async function routeSourcePostSASFrame(194+  postNoise: PostNoiseFrame,195+  localIdentity: LocalIdentity,196+  deps: SourcePostSASDeps,197+): Promise<PairedDevice | null> {198+  if (isAdoptRequest(postNoise.frame)) {199+    const sasConfirmed = await deps.confirmSAS(postNoise.sas);200+    if (!sasConfirmed) {201+      await closeIfPossible(postNoise.channel);202+      throw new ChirpPairingFailure("handshake_failed", "sas_rejected");203+    }204+    if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) {205+      await closeIfPossible(postNoise.channel);206+      throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported");207+    }208+209+    const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas);210+    if (!approved) {211+      await closeIfPossible(postNoise.channel);212+      throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");213+    }214+215+    const material = await deps.fetchAdoptionMaterial();216+    if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");217+    const rootKeyBytes = hexToBytes(material.rootKeyHex);218+    try {219+      const expectedVaultId = await computeVaultId(rootKeyBytes);220+      if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH");221+      const adopted = await grantAdoption(postNoise.channel, postNoise.frame, {222+        sourceIdentity: localIdentity,223+        rootKeyHex: async () => material.rootKeyHex,224+        vaultId: async () => material.vaultId,225+        license: async () => material.license,226+        onConsent: async () => true,227+        upsertPairedDevice: deps.completePairing,228+      });229+      if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName });230+      return adopted;231+    } finally {232+      rootKeyBytes.fill(0);233+    }234+  }235+236+  if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") {237+    const result = await handlePairIdentityFrame(238+      postNoise.frame,239+      postNoise.channel,240+      localIdentity,241+      postNoise.sas,242+    );243+    const paired = await gateOnSAS(deps, result);244+    await activatePeerLicenseIfAny(deps, result);245+    await deps.completePairing(paired);246+    return paired;247+  }248+249+  await closeIfPossible(postNoise.channel);250+  throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame");251+}252+176253 async function runResponderHandshake(177254   deps: ChirpPairingDeps,178255   attempt: ReturnType<typeof createPairingAttempt>,@@ -194,12 +271,11 @@194271   }195272 196273   const transport = await deps.openPairingTransport(peerEndpoint);197-  let result: PairingHandshakeResult;274+  let postNoise: PostNoiseFrame;198275   try {199-    result = await runPairingResponder(276+    postNoise = await runPairingResponderOpenFrame(200277       attempt.ephemeralPrivateKey,201278       attempt.ephemeralPublicKey,202-      localIdentity,203279       transport,204280     );205281   } catch (err) {@@ -207,20 +283,11 @@207283     deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });208284     throw new ChirpPairingFailure("handshake_failed", (err as Error).message);209285   }286+  const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps);210287   await transport.close();211-212-  // Out-of-band verification BEFORE persisting: the user must confirm the SAS213-  // matches the code on the other device. Without this, whoever completed the214-  // handshake (possibly a LAN/audio-range attacker) would be enrolled as a215-  // trusted sync peer (audit HIGH).216-  const paired = await gateOnSAS(deps, result);217-  await activatePeerLicenseIfAny(deps, result);218-219-  // The connection's IP is cached in `chrome.storage.local` keyed by220-  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3221-  // `PairedDevice` record itself has no `lastKnownIp` field because the222-  // IP is per-network and must not roam over `chrome.storage.sync`.223-  await deps.completePairing(paired);288+  if (!paired) {289+    throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");290+  }224291   await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);225292   deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });226293   return paired;@@ -233,7 +300,7 @@233300 // Exported for the PCH-01-024 regression test that pins the SAS gate as234301 // MANDATORY (the chirp-handshake.ts security comment depends on this staying true).235302 export async function gateOnSAS(236-  deps: ChirpPairingDeps,303+  deps: Pick<ChirpPairingDeps, "confirmSAS" | "onProgress">,237304   result: PairingHandshakeResult,238305 ): Promise<PairedDevice> {239306   let confirmed: boolean;@@ -259,7 +326,7 @@259326  * fails the pairing — the device link still succeeds.260327  */261328 async function activatePeerLicenseIfAny(262-  deps: ChirpPairingDeps,329+  deps: Pick<ChirpPairingDeps, "activatePeerLicense">,263330   result: PairingHandshakeResult,264331 ): Promise<void> {265332   if (!result.peerLicense || !deps.activatePeerLicense) return;@@ -268,6 +335,16 @@268335   } catch {269336     // Invalid signature, already-Pro, or storage error — pairing stands.270337   }338+}339+340+async function closeIfPossible(channel: unknown): Promise<void> {341+  await (channel as { close?: () => Promise<void> }).close?.();342+}343+344+function hexToBytes(hex: string): Uint8Array {345+  const out = new Uint8Array(hex.length / 2);346+  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);347+  return out;271348 }272349 273350 async function runInitiatorHandshake(

view file

src/popup/views/AdoptVaultView.test.tsx+13 −0
@@ -6,6 +6,19 @@66 import { AdoptVaultView } from "./AdoptVaultView";77 import { flushView, inputText, renderView } from "./view-test-utils";88 9+vi.mock("@zxcvbn-ts/core", () => ({10+  zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),11+  zxcvbnOptions: { setOptions: vi.fn() },12+}));13+vi.mock("@zxcvbn-ts/language-common", () => ({14+  adjacencyGraphs: {},15+  dictionary: {},16+}));17+vi.mock("@zxcvbn-ts/language-en", () => ({18+  translations: {},19+  dictionary: {},20+}));21+922 function button(container: HTMLElement, text: string): HTMLButtonElement {1023   const found = Array.from(container.querySelectorAll("button")).find((b) =>1124     (b.textContent ?? "").includes(text),

view file

src/popup/views/ChirpSync.tsx+52 −1
@@ -27,7 +27,9 @@2727   PairedDevice,2828   PairingProgress,2929 } from "../../shared/sync-types";30+import type { AdoptRequest } from "../../shared/chirp-adopt";3031 import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";32+import { AdoptionConsentModal } from "../components/AdoptionConsentModal";3133 import {3234   ChirpPairingFailure,3335   runChirpPairing,@@ -107,6 +109,11 @@107109   // is persisted (audit HIGH — pairing initiator authentication).108110   const [sasCode, setSasCode] = createSignal<string | null>(null);109111   const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);112+  const [adoptionConsent, setAdoptionConsent] = createSignal<{113+    request: AdoptRequest;114+    sas: string;115+    resolve: (ok: boolean) => void;116+  } | null>(null);110117 111118   function resolveSAS(ok: boolean) {112119     const r = sasResolve();@@ -271,6 +278,26 @@271278         })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };272279         if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();273280       },281+      grantAdoptionConsent: (request, sas) =>282+        new Promise<boolean>((resolve) => {283+          setAdoptionConsent({ request, sas, resolve });284+        }),285+      fetchAdoptionMaterial: async () => {286+        const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });287+        const r = res as {288+          ok: boolean;289+          data?: { rootKeyHex?: string; vaultId?: string; license?: string };290+          error?: string;291+        };292+        if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {293+          throw new Error(r.error ?? "adoption_material_unavailable");294+        }295+        return {296+          rootKeyHex: r.data.rootKeyHex,297+          vaultId: r.data.vaultId,298+          license: r.data.license,299+        };300+      },274301       getLocalSubnet,275302       openPairingTransport: async (endpoint) => {276303         // Native WebSocket transport for the pairing-mode peer scan. The@@ -532,5 +559,29 @@532559 533560   // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can534561   // receive Pro from an already-Pro peer. The upsell lives on the Settings card.535-  return pairingView();562+  return (563+    <>564+      {pairingView()}565+      <Show when={adoptionConsent()}>566+        {(pending) => (567+          <AdoptionConsentModal568+            receiverDeviceName={pending().request.device_name}569+            sas={pending().sas}570+            verifyMasterPassword={async (password) => {571+              const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });572+              return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);573+            }}574+            onCancel={() => {575+              pending().resolve(false);576+              setAdoptionConsent(null);577+            }}578+            onApprove={async () => {579+              pending().resolve(true);580+              setAdoptionConsent(null);581+            }}582+          />583+        )}584+      </Show>585+    </>586+  );536587 };

view file

src/shared/chirp-adopt.ts+7 −5
@@ -155,9 +155,9 @@155155   channel: PairingTransport,156156   request: AdoptRequest,157157   deps: AdoptionSourceDeps,158-): Promise<void> {158+): Promise<PairedDevice | null> {159159   const approved = await deps.onConsent(request);160-  if (!approved) return;160+  if (!approved) return null;161161 162162   const rootKeyHex = await deps.rootKeyHex();163163   if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");@@ -178,9 +178,9 @@178178   });179179 180180   const identity = await receiveFrame(channel);181-  if (!isAdoptIdentity(identity)) return;181+  if (!isAdoptIdentity(identity)) return null;182182 183-  await deps.upsertPairedDevice({183+  const device: PairedDevice = {184184     deviceId: identity.device_id,185185     deviceName: identity.device_name,186186     deviceType: identity.device_type,@@ -188,7 +188,9 @@188188     signingPublicKeyHex: identity.signing_public_key_hex,189189     trustSource: "direct",190190     pairedAt: Date.now(),191-  });191+  };192+  await deps.upsertPairedDevice(device);193+  return device;192194 }193195 194196 export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {

view file

src/shared/chirp-handshake.ts+57 −15
@@ -86,28 +86,39 @@8686   license?: string;8787 }8888 89+export interface PostNoiseFrame {90+  sas: string;91+  frame: unknown;92+  channel: PairingTransport;93+}94+8995 function encodeFrame(frame: PairingFrame): Uint8Array {9096   return new TextEncoder().encode(JSON.stringify(frame));9197 }9298 9399 function decodeFrame(bytes: Uint8Array): PairingFrame {94100   const parsed = JSON.parse(new TextDecoder().decode(bytes));95-  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");96-  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");97-  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");98-  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");99-  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");101+  return decodeParsedFrame(parsed);102+}103+104+function decodeParsedFrame(parsed: unknown): PairingFrame {105+  const value = parsed as Record<string, unknown> | null;106+  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");107+  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");108+  if (typeof value.device_name !== "string") throw new Error("device_name missing");109+  if (typeof value.device_type !== "string") throw new Error("device_type missing");110+  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");100111   // Long-term pubkey is a hex-encoded 32-byte X25519 key.101-  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {112+  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {102113     throw new Error("long_term_public_key_hex is not 32-byte hex");103114   }104115   if (105-    parsed.signing_public_key_hex !== undefined &&106-    !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)116+    value.signing_public_key_hex !== undefined &&117+    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))107118   ) {108119     throw new Error("signing_public_key_hex is not 32-byte hex");109120   }110-  return parsed as PairingFrame;121+  return value as unknown as PairingFrame;111122 }112123 113124 /**@@ -226,6 +237,19 @@226237   ourIdentity: LocalIdentity,227238   transport: PairingTransport,228239 ): Promise<PairingHandshakeResult> {240+  const postNoise = await runPairingResponderOpenFrame(241+    ourEphemeralPrivateKey,242+    ourEphemeralPublicKey,243+    transport,244+  );245+  return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);246+}247+248+export async function runPairingResponderOpenFrame(249+  ourEphemeralPrivateKey: Uint8Array,250+  ourEphemeralPublicKey: Uint8Array,251+  transport: PairingTransport,252+): Promise<PostNoiseFrame> {229253   const session = await noiseNKResponder(230254     ourEphemeralPrivateKey,231255     ourEphemeralPublicKey,@@ -233,11 +257,29 @@233257     transport.receive,234258   );235259 236-  // Receive theirs first, then send ours — the initiator sends first.237-  const theirCipher = await transport.receive();238-  const theirPlain = session.decrypt(theirCipher);239-  const theirFrame = decodeFrame(theirPlain);260+  const channel: PairingTransport = {261+    async send(msg) {262+      await transport.send(session.encrypt(msg));263+    },264+    async receive() {265+      return session.decrypt(await transport.receive());266+    },267+  };268+  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));269+  return {270+    sas: derivePairingSAS(session.handshakeHash),271+    frame: theirFrame,272+    channel,273+  };274+}240275 276+export async function handlePairIdentityFrame(277+  frame: unknown,278+  channel: PairingTransport,279+  ourIdentity: LocalIdentity,280+  sas: string,281+): Promise<PairingHandshakeResult> {282+  const theirFrame = decodeParsedFrame(frame);241283   const ourFrame = encodeFrame({242284     type: "PAIR_IDENTITY",243285     device_id: ourIdentity.deviceId,@@ -249,14 +291,14 @@249291     vault_id: ourIdentity.vaultId,250292     license: ourIdentity.license,251293   });252-  await transport.send(session.encrypt(ourFrame));294+  await channel.send(ourFrame);253295 254296   // SAS derived from the shared handshake hash — caller must have the user255297   // confirm it matches the peer's code before persisting (defeats the256298   // initiator-impersonation / MITM attack the NK handshake alone allows).257299   return {258300     device: frameToPairedDevice(theirFrame),259-    sas: derivePairingSAS(session.handshakeHash),301+    sas,260302     peerLicense: peerLicenseOf(theirFrame),261303   };262304 }

view file

src/shared/types.ts+1 −0
@@ -298,6 +298,7 @@298298   | "CHANGE_PASSWORD"299299   | "GET_SITE_PASSWORD_POLICY"300300   | "CHIRP_GET_LOCAL_IDENTITY"301+  | "CHIRP_GET_ADOPTION_MATERIAL"301302   | "CHIRP_COMPLETE_PAIRING"302303   | "CHIRP_LIST_PAIRED_DEVICES"303304   | "CHIRP_UNPAIR_DEVICE"

view file