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 { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";38import { getSyncSettings, relayInForce } from "./sync-settings";39import { activateLicense, getLicenseKey, isProUser } from "./license";40import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41import { computeVaultId } from "../shared/sync-identity";42import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";43import { storageRemove, storageSet } from "../shared/storage";44import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45import { isValidPairSecret, setPairSecret } from "./pair-secrets";46import { createSyncVaultAdapter } from "./sync-vault-adapter";47import { triggerSyncIfReachable } from "./sync-trigger";48import { schedulePostAdoptionSync } from "./sync-alarm";4950const SOURCE_WINDOW_MS = 5 * 60 * 1000;5152function push(type: string, payload: unknown): void {53 try {54 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;55 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {56 (maybe as Promise<unknown>).catch(() => undefined);57 }58 } catch {59 60 }61}626364function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {65 return new Promise<T>((resolve, reject) => {66 const timer = setTimeout(() => reject(new Error("timeout")), ms);67 promise.then(68 (value) => {69 clearTimeout(timer);70 resolve(value);71 },72 (error) => {73 clearTimeout(timer);74 reject(error);75 },76 );77 });78}7980let inFlight = false;81let activeSocket: PairingSocket | null = null;8283848586const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();87const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8889export function isRelayAdoptionSourceInFlight(): boolean {90 return inFlight;91}929394959697export async function startRelayAdoptionSource(): Promise<{98 invitation: string;99}> {100 if (inFlight) throw new Error("adoption_source_in_progress");101 if (!isUnlocked()) throw new Error("vault_locked");102 inFlight = true;103104 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;105 try {106 const settings = await getSyncSettings();107 const relayUrl = relayInForce(settings);108 local = await getLocalPairingIdentity();109 const token = generatePairingRendezvousToken();110 111 112 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));113 const invitation = parsePairingInvitation(encoded);114 if (!invitation) throw new Error("invitation_generation_failed");115116 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));117 activeSocket = socket;118 const readyLocal = local;119 void runSourceSession(120 socket,121 token,122 relayUrl,123 readyLocal,124 ).finally(() => {125 readyLocal.privateKey.fill(0);126 readyLocal.publicKey.fill(0);127 clearState();128 });129 return { invitation: encoded };130 } catch (error) {131 local?.privateKey.fill(0);132 local?.publicKey.fill(0);133 clearState();134 throw error;135 }136}137138export function cancelRelayAdoptionSource(): void {139 activeSocket?.close();140 clearState();141}142143144145146147148149150151152153154export async function startRelayAdoptionReceiver(155 password: string,156): Promise<{ invitation: string }> {157 if (inFlight) throw new Error("adoption_receiver_in_progress");158 if (typeof password !== "string" || password.length === 0) {159 throw new Error("ADOPT_INVALID_PASSWORD");160 }161 inFlight = true;162 try {163 const settings = await getSyncSettings();164 const relayUrl = relayInForce(settings);165 const token = generatePairingRendezvousToken();166 const ephemeral = generateKeyPair();167 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));168 const invitation = parsePairingInvitation(encoded);169 if (!invitation) throw new Error("invitation_generation_failed");170 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));171 activeSocket = socket;172 void runReceiverSession(173 socket,174 token,175 relayUrl,176 password,177 ephemeral,178 ).finally(() => {179 ephemeral.secretKey.fill(0);180 ephemeral.publicKey.fill(0);181 clearState();182 });183 return { invitation: encoded };184 } catch (error) {185 clearState();186 throw error;187 }188}189190191192193194195196197export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {198 const pending = pendingSourceSasConfirmation.get(id);199 if (!pending) return false;200 pendingSourceSasConfirmation.delete(id);201 pending.resolve(ok);202 return true;203}204205export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {206 const pending = pendingReceiverSasConfirmation.get(id);207 if (!pending) return false;208 pendingReceiverSasConfirmation.delete(id);209 pending.resolve(ok && constantTimeEqual(pending.sas, sas));210 return true;211}212213async function runSourceSession(214 socket: PairingSocket,215 expectedToken: string,216 relayUrl: string,217 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,218): Promise<void> {219 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);220 let noise: NoiseNKSession | undefined;221 try {222 const hello = decodeJson<{ type?: unknown; token?: unknown }>(223 await socket.receive(),224 );225 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||226 !constantTimeEqual(hello.token, expectedToken)) {227 throw new Error("invalid_invitation");228 }229 socket.send(encodeJson({230 type: "PEACH_HELLO_ACK",231 ephemeralPublicKeyHex: bytesToHex(local.publicKey),232 }));233234 const session = await noiseNKResponder(235 local.privateKey,236 local.publicKey,237 async (bytes) => socket.send(bytes),238 () => socket.receive(),239 );240 noise = session;241 const channel: PairingTransport = {242 async send(plaintext) { socket.send(session.encrypt(plaintext)); },243 async receive() { return session.decrypt(await socket.receive()); },244 };245 const request = await receiveFrame(channel);246 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");247248 const device = await grantAdoption(channel, request, {249 sourceIdentity: local.identity,250 pairState: {251 rendezvousSecretHex: bytesToHex(252 deriveRendezvousSecret(session.handshakeHash),253 ),254 relayUrl,255 },256 async rootKeyHex() {257 258 259 const value = getRootKeyHex();260 if (!value) throw new Error("vault_locked");261 return value;262 },263 async vaultId() {264 const value = getRootKeyHex();265 if (!value) throw new Error("vault_locked");266 const bytes = hexToBytes(value);267 try {268 return await computeVaultId(bytes);269 } finally {270 bytes.fill(0);271 }272 },273 async license() {274 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;275 },276 async applyLicense(license) {277 await applyTransferredLicense(license);278 },279 onConsent: (adoptRequest) => requestConsent(280 adoptRequest,281 derivePairingSAS(session.handshakeHash),282 crypto.randomUUID(),283 ),284 upsertPairedDevice: storeAdoptedDevice,285 });286 if (!device) {287 push("ADOPTION_SOURCE_RESULT", {288 status: "error",289 error: "adoption_rejected",290 });291 return;292 }293 294 295 296 if (request.snapshot_capable === true) {297 try {298 const adapter = createSyncVaultAdapter();299 const entryManifest = await adapter.buildEntryManifest();300 const entryIds = Object.keys(entryManifest);301 const entries =302 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};303 const tombstones = await adapter.buildTombstoneManifest();304 let duressSlot: string | null = null;305 try {306 duressSlot = await exportDuressSlotForAdoption();307 } catch {308 309 }310 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);311312 const applied = await withTimeout(receiveFrame(channel), 15_000);313 if (isAdoptSnapshotApplied(applied)) {314 console.info(315 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,316 );317 }318319 if (adapter.buildOutgoingDeviceManifest) {320 const manifest = await adapter.buildOutgoingDeviceManifest();321 if (manifest) {322 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });323 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);324 }325 }326 } catch (snapshotError) {327 if (328 snapshotError instanceof Error &&329 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"330 ) {331 throw snapshotError;332 }333 console.info(334 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",335 snapshotError,336 );337 }338 }339 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });340 341 342 343 void triggerSyncIfReachable({ reason: "manual", force: true });344 schedulePostAdoptionSync();345 } catch (error) {346 push("ADOPTION_SOURCE_RESULT", {347 status: "error",348 error: error instanceof Error ? error.message : "adoption_failed",349 });350 } finally {351 clearTimeout(timeout);352 noise?.close();353 socket.close();354 pendingSourceSasConfirmation.clear();355 }356}357358359360361362363async function runReceiverSession(364 socket: PairingSocket,365 expectedToken: string,366 relayUrl: string,367 password: string,368 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },369): Promise<void> {370 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);371 372 const ephemeral = invitationEphemeral;373 const ephemeralPrivate = ephemeral.secretKey;374 const ephemeralPublic = ephemeral.publicKey;375 let noise: NoiseNKSession | undefined;376 let createdVault = false;377 let protocolError: string | undefined;378 try {379 const hello = decodeJson<{ type?: unknown; token?: unknown }>(380 await socket.receive(),381 );382 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||383 !constantTimeEqual(hello.token, expectedToken)) {384 throw new Error("invalid_invitation");385 }386 socket.send(encodeJson({387 type: "PEACH_HELLO_ACK",388 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),389 }));390391 const session = await noiseNKResponder(392 ephemeralPrivate,393 ephemeralPublic,394 async (bytes) => socket.send(bytes),395 () => socket.receive(),396 );397 noise = session;398 const channel: PairingTransport = {399 async send(plaintext) { socket.send(session.encrypt(plaintext)); },400 async receive() { return session.decrypt(await socket.receive()); },401 };402403 const deviceName = await getDeviceName();404405 const adoptionResult = await requestAdoption(channel, {406 deviceName,407 generateDeviceId: () => crypto.randomUUID(),408 async confirmSas() {409 const sas = derivePairingSAS(session.handshakeHash);410 const id = crypto.randomUUID();411 return waitForReceiverSasConfirmation(id, sas);412 },413 async createVaultFromRootKey(rootKeyHex) {414 415 416 417 418 419 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);420 createdVault = true;421 return { vaultId };422 },423 async applyLicense(license) {424 425 426 await applyTransferredLicense(license);427 },428 async license() {429 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;430 },431 async deriveLocalIdentity() {432 433 const masterKeyHex = getRootKeyHex();434 if (!masterKeyHex) throw new Error("identity_unavailable");435 const masterKey = hexToBytes(masterKeyHex);436 try {437 return await buildLocalIdentity(masterKey);438 } finally {439 masterKey.fill(0);440 }441 },442 async upsertPairedDevice(paired) {443 444 await storeAdoptedDevice(paired);445 },446 pairState: {447 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),448 relayUrl,449 },450 onError(error) {451 protocolError = error;452 },453 });454455 if (!adoptionResult) {456 if (createdVault) {457 await rollbackAdoptedVault();458 createdVault = false;459 }460 push("ADOPTION_SOURCE_RESULT", {461 status: "error",462 error: protocolError ?? "adoption_rejected",463 });464 return;465 }466 const { device, snapshotCapable } = adoptionResult;467 468 469 470 471 472 let snapshotApplied = false;473 if (snapshotCapable) try {474 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);475 const adapter = createSyncVaultAdapter();476 await adapter.applyEntries(snapshot.entries);477 await adapter.applyTombstones(snapshot.tombstones);478 479 480 if (snapshot.duressSlot) {481 try {482 await installAdoptedDuressSlot(snapshot.duressSlot);483 } catch (duressError) {484 console.info(485 "[Peach] adopted duress slot install failed (non-fatal):",486 duressError,487 );488 }489 }490 await sendFrame(channel, {491 type: "ADOPT_SNAPSHOT_APPLIED",492 entries_received: Object.keys(snapshot.entries).length,493 tombstones_received: Object.keys(snapshot.tombstones).length,494 });495496 497 498 try {499 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);500 if (501 isAdoptDeviceManifestFrame(manifestFrame) &&502 adapter.applyIncomingDeviceManifest &&503 typeof device.longTermPublicKeyHex === "string" &&504 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)505 ) {506 await adapter.applyIncomingDeviceManifest(507 manifestFrame.manifest,508 hexToBytes(device.longTermPublicKeyHex),509 );510 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });511 }512 } catch {513 514 }515516 await markLastSyncedAt(device.deviceId, Date.now());517 snapshotApplied = true;518 } catch (snapshotError) {519 console.info(520 "[Peach] inline snapshot failed, falling back to sync pipeline:",521 snapshotError,522 );523 }524525 526 527 528 529 530 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(531 () => undefined,532 );533 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });534 createdVault = false;535536 if (snapshotApplied) {537 538 539 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);540 } else {541 542 543 void triggerSyncIfReachable({ reason: "manual", force: true });544 schedulePostAdoptionSync();545 }546 } catch (error) {547 if (createdVault) {548 try {549 await rollbackAdoptedVault();550 createdVault = false;551 } catch (rollbackError) {552 console.error("[Peach] rollback failed:", rollbackError);553 }554 }555 console.error("[Peach] receiver session failed:", error);556 push("ADOPTION_SOURCE_RESULT", {557 status: "error",558 error: error instanceof Error ? error.message : "adoption_failed",559 });560 } finally {561 clearTimeout(timeout);562 noise?.close();563 socket.close();564 ephemeralPrivate.fill(0);565 ephemeralPublic.fill(0);566 }567}568569570571572573574async function applyTransferredLicense(license: string): Promise<void> {575 const activated = await activateLicense(license);576 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {577 throw new Error("license_transfer_failed");578 }579}580581582583584585586587588589590591function waitForReceiverSasConfirmation(id: string, sas: string): Promise<boolean> {592 return new Promise<boolean>((resolve) => {593 const timer = setTimeout(() => {594 pendingReceiverSasConfirmation.delete(id);595 resolve(false);596 }, SOURCE_WINDOW_MS);597 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {598 clearTimeout(timer);599 resolve(ok);600 } });601 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });602 });603}604605function waitForSourceSasConfirmation(606 id: string,607 request: AdoptRequest,608 sas: string,609): Promise<boolean> {610 return new Promise<boolean>((resolve) => {611 const timer = setTimeout(() => {612 pendingSourceSasConfirmation.delete(id);613 resolve(false);614 }, SOURCE_WINDOW_MS);615 pendingSourceSasConfirmation.set(id, {616 resolve: (ok) => {617 clearTimeout(timer);618 resolve(ok);619 },620 });621 push("ADOPTION_SOURCE_SAS", {622 id,623 sas,624 receiverDeviceName: request.device_name,625 });626 });627}628629630631632633634635function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {636 return waitForSourceSasConfirmation(id, request, sas);637}638639function clearState(): void {640 activeSocket = null;641 inFlight = false;642 pendingSourceSasConfirmation.clear();643 pendingReceiverSasConfirmation.clear();644}645646function constantTimeEqual(a: string, b: string): boolean {647 if (a.length !== b.length) return false;648 let diff = 0;649 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);650 return diff === 0;651}652653function encodeJson(value: unknown): Uint8Array {654 return new TextEncoder().encode(JSON.stringify(value));655}656657function decodeJson<T>(value: Uint8Array): T {658 return JSON.parse(new TextDecoder().decode(value)) as T;659}660661662663664665666667async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {668 const secret = paired.rendezvousSecretHex;669 const pub = { ...paired };670 delete pub.rendezvousSecretHex;671 await upsertPairedDevice(pub);672 if (!isValidPairSecret(secret)) return;673 const masterKeyHex = getRootKeyHex();674 if (!masterKeyHex) return;675 const masterKey = hexToBytes(masterKeyHex);676 try {677 await setPairSecret(paired.deviceId, secret, masterKey);678 } finally {679 masterKey.fill(0);680 }681}682683function hexToBytes(hex: string): Uint8Array {684 const out = new Uint8Array(hex.length / 2);685 for (let i = 0; i < out.length; i++) {686 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);687 }688 return out;689}