1import { generateKeyPair } from "@stablelib/x25519";2import {3 createPairingInvitation,4 pairingInvitationUrl,5 parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8 openRelayPairingSocket,9 type PairingSocket,10} from "../shared/pairing-dial";11import {12 noiseNKResponder,13 type NoiseNKSession,14} from "../shared/noise-js";15import {16 grantAdoption,17 isAdoptDeviceManifestFrame,18 isAdoptRequest,19 isAdoptSnapshotApplied,20 receiveFrame,21 receiveVaultSnapshot,22 requestAdoption,23 sendFrame,24 sendVaultSnapshot,25 type AdoptRequest,26} from "../shared/vault-adopt";27import {28 derivePairingSAS,29 type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33 bytesToHex,34 deriveRendezvousSecret,35 generatePairingRendezvousToken,36} from "../shared/rendezvous";37import {38 buildLocalIdentity,39 getDeviceName,40 getLocalPairingIdentity,41 rotateDeviceId,42} from "./device-identity";43import { getSyncSettings, relayInForce } from "./sync-settings";44import { activateLicense, getLicenseKey, isProUser } from "./license";45import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";46import { deferAutoLockForOperation } from "./alarms";47import { computeVaultId } from "../shared/sync-identity";48import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";49import { storageRemove, storageSet } from "../shared/storage";50import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";51import { isValidPairSecret, setPairSecret } from "./pair-secrets";52import { createSyncVaultAdapter } from "./sync-vault-adapter";53import { triggerSyncIfReachable } from "./sync-trigger";54import { schedulePostAdoptionSync } from "./sync-alarm";5556const SOURCE_WINDOW_MS = 5 * 60 * 1000;5758function push(type: string, payload: unknown): void {59 try {60 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;61 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {62 (maybe as Promise<unknown>).catch(() => undefined);63 }64 } catch {65 66 }67}686970function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {71 return new Promise<T>((resolve, reject) => {72 const timer = setTimeout(() => reject(new Error("timeout")), ms);73 promise.then(74 (value) => {75 clearTimeout(timer);76 resolve(value);77 },78 (error) => {79 clearTimeout(timer);80 reject(error);81 },82 );83 });84}8586let inFlight = false;87let activeSocket: PairingSocket | null = null;8889909192const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();93const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();9495export function isRelayAdoptionSourceInFlight(): boolean {96 return inFlight;97}9899100101102103export async function startRelayAdoptionSource(): Promise<{104 invitation: string;105}> {106 if (inFlight) throw new Error("adoption_source_in_progress");107 if (!isUnlocked()) throw new Error("vault_locked");108 inFlight = true;109 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);110111 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;112 try {113 const settings = await getSyncSettings();114 const relayUrl = relayInForce(settings);115 local = await getLocalPairingIdentity();116 const token = generatePairingRendezvousToken();117 118 119 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));120 const invitation = parsePairingInvitation(encoded);121 if (!invitation) throw new Error("invitation_generation_failed");122123 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));124 activeSocket = socket;125 const readyLocal = local;126 void runSourceSession(127 socket,128 token,129 relayUrl,130 readyLocal,131 ).finally(() => {132 readyLocal.privateKey.fill(0);133 readyLocal.publicKey.fill(0);134 clearState();135 releaseAutoLock();136 });137 return { invitation: encoded };138 } catch (error) {139 local?.privateKey.fill(0);140 local?.publicKey.fill(0);141 clearState();142 releaseAutoLock();143 throw error;144 }145}146147export function cancelRelayAdoptionSource(): void {148 activeSocket?.close();149 clearState();150}151152153154155156157158159160161162163export async function startRelayAdoptionReceiver(164 password: string,165): Promise<{ invitation: string }> {166 if (inFlight) throw new Error("adoption_receiver_in_progress");167 if (typeof password !== "string" || password.length === 0) {168 throw new Error("ADOPT_INVALID_PASSWORD");169 }170 inFlight = true;171 try {172 const settings = await getSyncSettings();173 const relayUrl = relayInForce(settings);174 const token = generatePairingRendezvousToken();175 const ephemeral = generateKeyPair();176 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));177 const invitation = parsePairingInvitation(encoded);178 if (!invitation) throw new Error("invitation_generation_failed");179 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));180 activeSocket = socket;181 void runReceiverSession(182 socket,183 token,184 relayUrl,185 password,186 ephemeral,187 ).finally(() => {188 ephemeral.secretKey.fill(0);189 ephemeral.publicKey.fill(0);190 clearState();191 });192 return { invitation: encoded };193 } catch (error) {194 clearState();195 throw error;196 }197}198199200201202203204205206export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {207 const pending = pendingSourceSasConfirmation.get(id);208 if (!pending) return false;209 pendingSourceSasConfirmation.delete(id);210 pending.resolve(ok);211 return true;212}213214export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {215 const pending = pendingReceiverSasConfirmation.get(id);216 if (!pending) return false;217 pendingReceiverSasConfirmation.delete(id);218 pending.resolve(ok && constantTimeEqual(pending.sas, sas));219 return true;220}221222async function runSourceSession(223 socket: PairingSocket,224 expectedToken: string,225 relayUrl: string,226 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,227): Promise<void> {228 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);229 let noise: NoiseNKSession | undefined;230 try {231 const hello = decodeJson<{ type?: unknown; token?: unknown }>(232 await socket.receive(),233 );234 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||235 !constantTimeEqual(hello.token, expectedToken)) {236 throw new Error("invalid_invitation");237 }238 socket.send(encodeJson({239 type: "PEACH_HELLO_ACK",240 ephemeralPublicKeyHex: bytesToHex(local.publicKey),241 }));242243 const session = await noiseNKResponder(244 local.privateKey,245 local.publicKey,246 async (bytes) => socket.send(bytes),247 () => socket.receive(),248 );249 noise = session;250 const channel: PairingTransport = {251 async send(plaintext) { socket.send(session.encrypt(plaintext)); },252 async receive() { return session.decrypt(await socket.receive()); },253 };254 const request = await receiveFrame(channel);255 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");256257 const device = await grantAdoption(channel, request, {258 sourceIdentity: local.identity,259 pairState: {260 rendezvousSecretHex: bytesToHex(261 deriveRendezvousSecret(session.handshakeHash),262 ),263 relayUrl,264 },265 async rootKeyHex() {266 267 268 const value = getRootKeyHex();269 if (!value) throw new Error("vault_locked");270 return value;271 },272 async vaultId() {273 const value = getRootKeyHex();274 if (!value) throw new Error("vault_locked");275 const bytes = hexToBytes(value);276 try {277 return await computeVaultId(bytes);278 } finally {279 bytes.fill(0);280 }281 },282 async license() {283 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;284 },285 async applyLicense(license) {286 await applyTransferredLicense(license);287 },288 onConsent: (adoptRequest) => requestConsent(289 adoptRequest,290 derivePairingSAS(session.handshakeHash),291 crypto.randomUUID(),292 ),293 upsertPairedDevice: storeAdoptedDevice,294 });295 if (!device) {296 push("ADOPTION_SOURCE_RESULT", {297 status: "error",298 error: "adoption_rejected",299 });300 return;301 }302 303 304 305 if (request.snapshot_capable === true) {306 try {307 const adapter = createSyncVaultAdapter();308 const entryManifest = await adapter.buildEntryManifest();309 const entryIds = Object.keys(entryManifest);310 const entries =311 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};312 const tombstones = await adapter.buildTombstoneManifest();313 let duressSlot: string | null = null;314 try {315 duressSlot = await exportDuressSlotForAdoption();316 } catch {317 318 }319 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);320321 const applied = await withTimeout(receiveFrame(channel), 15_000);322 if (isAdoptSnapshotApplied(applied)) {323 console.info(324 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,325 );326 }327328 if (adapter.buildOutgoingDeviceManifest) {329 const manifest = await adapter.buildOutgoingDeviceManifest();330 if (manifest) {331 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });332 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);333 }334 }335 } catch (snapshotError) {336 if (337 snapshotError instanceof Error &&338 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"339 ) {340 throw snapshotError;341 }342 console.info(343 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",344 snapshotError,345 );346 }347 }348 push("ADOPTION_SOURCE_RESULT", { status: "complete" });349 350 351 352 void triggerSyncIfReachable({ reason: "manual", force: true });353 schedulePostAdoptionSync();354 } catch (error) {355 push("ADOPTION_SOURCE_RESULT", {356 status: "error",357 error: error instanceof Error ? error.message : "adoption_failed",358 });359 } finally {360 clearTimeout(timeout);361 noise?.close();362 socket.close();363 pendingSourceSasConfirmation.clear();364 }365}366367368369370371372async function runReceiverSession(373 socket: PairingSocket,374 expectedToken: string,375 relayUrl: string,376 password: string,377 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },378): Promise<void> {379 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);380 381 const ephemeral = invitationEphemeral;382 const ephemeralPrivate = ephemeral.secretKey;383 const ephemeralPublic = ephemeral.publicKey;384 let noise: NoiseNKSession | undefined;385 let createdVault = false;386 let protocolError: string | undefined;387 try {388 const hello = decodeJson<{ type?: unknown; token?: unknown }>(389 await socket.receive(),390 );391 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||392 !constantTimeEqual(hello.token, expectedToken)) {393 throw new Error("invalid_invitation");394 }395 socket.send(encodeJson({396 type: "PEACH_HELLO_ACK",397 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),398 }));399400 const session = await noiseNKResponder(401 ephemeralPrivate,402 ephemeralPublic,403 async (bytes) => socket.send(bytes),404 () => socket.receive(),405 );406 noise = session;407 const channel: PairingTransport = {408 async send(plaintext) { socket.send(session.encrypt(plaintext)); },409 async receive() { return session.decrypt(await socket.receive()); },410 };411412 const deviceName = await getDeviceName();413414 const adoptionResult = await requestAdoption(channel, {415 deviceName,416 generateDeviceId: () => crypto.randomUUID(),417 async confirmSas() {418 const sas = derivePairingSAS(session.handshakeHash);419 const id = crypto.randomUUID();420 return waitForReceiverSasConfirmation(id, sas);421 },422 async createVaultFromRootKey(rootKeyHex) {423 424 425 426 427 428 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);429 createdVault = true;430 return { vaultId };431 },432 async applyLicense(license) {433 434 435 await applyTransferredLicense(license);436 },437 async license() {438 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;439 },440 async deriveLocalIdentity() {441 442 const masterKeyHex = getRootKeyHex();443 if (!masterKeyHex) throw new Error("identity_unavailable");444 const masterKey = hexToBytes(masterKeyHex);445 try {446 return await buildLocalIdentity(masterKey);447 } finally {448 masterKey.fill(0);449 }450 },451 async rotateLocalIdentity() {452 await rotateDeviceId();453 const masterKeyHex = getRootKeyHex();454 if (!masterKeyHex) throw new Error("identity_unavailable");455 const masterKey = hexToBytes(masterKeyHex);456 try {457 return await buildLocalIdentity(masterKey);458 } finally {459 masterKey.fill(0);460 }461 },462 async upsertPairedDevice(paired) {463 464 await storeAdoptedDevice(paired);465 },466 pairState: {467 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),468 relayUrl,469 },470 onError(error) {471 protocolError = error;472 },473 });474475 if (!adoptionResult) {476 if (createdVault) {477 await rollbackAdoptedVault();478 createdVault = false;479 }480 push("ADOPTION_SOURCE_RESULT", {481 status: "error",482 error: protocolError ?? "adoption_rejected",483 });484 return;485 }486 const { device, snapshotCapable } = adoptionResult;487 488 489 490 491 492 let snapshotApplied = false;493 if (snapshotCapable) try {494 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);495 const adapter = createSyncVaultAdapter();496 await adapter.applyEntries(snapshot.entries);497 await adapter.applyTombstones(snapshot.tombstones);498 499 500 if (snapshot.duressSlot) {501 try {502 await installAdoptedDuressSlot(snapshot.duressSlot);503 } catch (duressError) {504 console.info(505 "[Peach] adopted duress slot install failed (non-fatal):",506 duressError,507 );508 }509 }510 await sendFrame(channel, {511 type: "ADOPT_SNAPSHOT_APPLIED",512 entries_received: Object.keys(snapshot.entries).length,513 tombstones_received: Object.keys(snapshot.tombstones).length,514 });515516 517 518 try {519 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);520 if (521 isAdoptDeviceManifestFrame(manifestFrame) &&522 adapter.applyIncomingDeviceManifest &&523 typeof device.longTermPublicKeyHex === "string" &&524 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)525 ) {526 await adapter.applyIncomingDeviceManifest(527 manifestFrame.manifest,528 hexToBytes(device.longTermPublicKeyHex),529 );530 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });531 }532 } catch {533 534 }535536 await markLastSyncedAt(device.deviceId, Date.now());537 snapshotApplied = true;538 } catch (snapshotError) {539 console.info(540 "[Peach] inline snapshot failed, falling back to sync pipeline:",541 snapshotError,542 );543 }544545 546 547 548 549 550 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(551 () => undefined,552 );553 push("ADOPTION_SOURCE_RESULT", { status: "complete" });554 createdVault = false;555556 if (snapshotApplied) {557 558 559 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);560 } else {561 562 563 void triggerSyncIfReachable({ reason: "manual", force: true });564 schedulePostAdoptionSync();565 }566 } catch (error) {567 if (createdVault) {568 try {569 await rollbackAdoptedVault();570 createdVault = false;571 } catch (rollbackError) {572 console.error("[Peach] rollback failed:", rollbackError);573 }574 }575 console.error("[Peach] receiver session failed:", error);576 push("ADOPTION_SOURCE_RESULT", {577 status: "error",578 error: error instanceof Error ? error.message : "adoption_failed",579 });580 } finally {581 clearTimeout(timeout);582 noise?.close();583 socket.close();584 ephemeralPrivate.fill(0);585 ephemeralPublic.fill(0);586 }587}588589590591592593594async function applyTransferredLicense(license: string): Promise<void> {595 const activated = await activateLicense(license);596 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {597 throw new Error("license_transfer_failed");598 }599}600601602603604605606607608609610611function waitForReceiverSasConfirmation(612 id: string,613 sas: string,614): Promise<boolean> {615 return new Promise<boolean>((resolve) => {616 const timer = setTimeout(() => {617 pendingReceiverSasConfirmation.delete(id);618 resolve(false);619 }, SOURCE_WINDOW_MS);620 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {621 clearTimeout(timer);622 resolve(ok);623 } });624 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });625 });626}627628function waitForSourceSasConfirmation(629 id: string,630 request: AdoptRequest,631 sas: string,632): Promise<boolean> {633 return new Promise<boolean>((resolve) => {634 const timer = setTimeout(() => {635 pendingSourceSasConfirmation.delete(id);636 resolve(false);637 }, SOURCE_WINDOW_MS);638 pendingSourceSasConfirmation.set(id, {639 resolve: (ok) => {640 clearTimeout(timer);641 resolve(ok);642 },643 });644 push("ADOPTION_SOURCE_SAS", {645 id,646 sas,647 receiverDeviceName: request.device_name,648 });649 });650}651652653654655656657658function requestConsent(659 request: AdoptRequest,660 sas: string,661 id: string,662): Promise<boolean> {663 return waitForSourceSasConfirmation(id, request, sas);664}665666function clearState(): void {667 activeSocket = null;668 inFlight = false;669 pendingSourceSasConfirmation.clear();670 pendingReceiverSasConfirmation.clear();671}672673function constantTimeEqual(a: string, b: string): boolean {674 if (a.length !== b.length) return false;675 let diff = 0;676 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);677 return diff === 0;678}679680function encodeJson(value: unknown): Uint8Array {681 return new TextEncoder().encode(JSON.stringify(value));682}683684function decodeJson<T>(value: Uint8Array): T {685 return JSON.parse(new TextDecoder().decode(value)) as T;686}687688689690691692693694async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {695 const secret = paired.rendezvousSecretHex;696 const pub = { ...paired };697 delete pub.rendezvousSecretHex;698 await upsertPairedDevice(pub);699 if (!isValidPairSecret(secret)) return;700 const masterKeyHex = getRootKeyHex();701 if (!masterKeyHex) return;702 const masterKey = hexToBytes(masterKeyHex);703 try {704 await setPairSecret(paired.deviceId, secret, masterKey);705 } finally {706 masterKey.fill(0);707 }708}709710function hexToBytes(hex: string): Uint8Array {711 const out = new Uint8Array(hex.length / 2);712 for (let i = 0; i < out.length; i++) {713 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);714 }715 return out;716}