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
↓ peach-browser-5845cedc.tar.gzatlas at this save
files changed11 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 −0manifest.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": {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",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",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 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+});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(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),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 };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> {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 }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"