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 sendFrame,23 sendVaultSnapshot,24 type AdoptionSourceDeps,25} from "../shared/vault-adopt";26import {27 grantAdoptionV2,28 isAdoptRequestV2,29 requestAdoptionV2,30 type AdoptionV2ReceiverDeps,31 type AdoptionV2SourceDeps,32} from "../shared/adoption-protocol";33import {34 derivePairingSAS,35 type PairingTransport,36} from "../shared/pairing-handshake";37import type { PairedDevice } from "../shared/sync-types";38import {39 bytesToHex,40 deriveRendezvousSecret,41 generatePairingRendezvousToken,42} from "../shared/rendezvous";43import {44 buildLocalIdentity,45 getDeviceName,46 getLocalPairingIdentity,47 preparePreVaultIdentity,48 readPreVaultIdentity,49 rotatePreVaultIdentity,50 rotateDeviceId,51} from "./device-identity";52import {53 browserGenerationIo,54 isPostReset,55 onDirectV2PairingEstablished,56} from "./sync-generation";57import {58 hasAdoptionIdentityConflict,59 markLastSyncedAt,60 upsertPairedDeviceAndClearRevocation,61} from "./paired-devices";62import { getSyncSettings, relayInForce } from "./sync-settings";63import { activateLicense, getLicenseKey, isProUser } from "./license";64import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";65import { deferAutoLockForOperation } from "./alarms";66import { computeVaultId } from "../shared/sync-identity";67import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";68import { storageRemove, storageSet } from "../shared/storage";69import {70 persistPendingAdoptionHolder,71 persistPendingAdoptionIdentity,72 readPendingAdoptionHolder,73 removePendingAdoptionHolder,74 completePendingAdoptionIdentity,75} from "./pending-adoption-holder";76import { isValidPairSecret, setPairSecret } from "./pair-secrets";77import { createSyncVaultAdapter } from "./sync-vault-adapter";78import { triggerSyncIfReachable } from "./sync-trigger";79import { schedulePostAdoptionSync } from "./sync-alarm";8081const SOURCE_WINDOW_MS = 5 * 60 * 1000;8283function push(type: string, payload: unknown): void {84 try {85 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;86 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {87 (maybe as Promise<unknown>).catch(() => undefined);88 }89 } catch {90 91 }92}939495function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {96 return new Promise<T>((resolve, reject) => {97 const timer = setTimeout(() => reject(new Error("timeout")), ms);98 promise.then(99 (value) => {100 clearTimeout(timer);101 resolve(value);102 },103 (error) => {104 clearTimeout(timer);105 reject(error);106 },107 );108 });109}110111let inFlight = false;112let activeSocket: PairingSocket | null = null;113114115116117const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();118const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();119120export function isRelayAdoptionSourceInFlight(): boolean {121 return inFlight;122}123124125126127128export async function startRelayAdoptionSource(): Promise<{129 invitation: string;130}> {131 if (inFlight) throw new Error("adoption_source_in_progress");132 if (!isUnlocked()) throw new Error("vault_locked");133 inFlight = true;134 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);135136 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;137 try {138 const settings = await getSyncSettings();139 const relayUrl = relayInForce(settings);140 local = await getLocalPairingIdentity();141 const token = generatePairingRendezvousToken();142 143 144 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));145 const invitation = parsePairingInvitation(encoded);146 if (!invitation) throw new Error("invitation_generation_failed");147148 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));149 activeSocket = socket;150 const readyLocal = local;151 void runSourceSession(152 socket,153 token,154 relayUrl,155 readyLocal,156 ).finally(() => {157 readyLocal.privateKey.fill(0);158 readyLocal.publicKey.fill(0);159 clearState();160 releaseAutoLock();161 });162 return { invitation: encoded };163 } catch (error) {164 local?.privateKey.fill(0);165 local?.publicKey.fill(0);166 clearState();167 releaseAutoLock();168 throw error;169 }170}171172export function cancelRelayAdoptionSource(): void {173 activeSocket?.close();174 clearState();175}176177178179180181182183184185186187188export async function startRelayAdoptionReceiver(189 password: string,190): Promise<{ invitation: string }> {191 if (inFlight) throw new Error("adoption_receiver_in_progress");192 if (typeof password !== "string" || password.length === 0) {193 throw new Error("ADOPT_INVALID_PASSWORD");194 }195 inFlight = true;196 try {197 await preparePreVaultIdentity(password);198 const settings = await getSyncSettings();199 const relayUrl = relayInForce(settings);200 const token = generatePairingRendezvousToken();201 const ephemeral = generateKeyPair();202 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));203 const invitation = parsePairingInvitation(encoded);204 if (!invitation) throw new Error("invitation_generation_failed");205 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));206 activeSocket = socket;207 void runReceiverSession(208 socket,209 token,210 relayUrl,211 password,212 ephemeral,213 ).finally(() => {214 ephemeral.secretKey.fill(0);215 ephemeral.publicKey.fill(0);216 clearState();217 });218 return { invitation: encoded };219 } catch (error) {220 clearState();221 throw error;222 }223}224225226227228229230231232export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {233 const pending = pendingSourceSasConfirmation.get(id);234 if (!pending) return false;235 pendingSourceSasConfirmation.delete(id);236 pending.resolve(ok);237 return true;238}239240export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {241 const pending = pendingReceiverSasConfirmation.get(id);242 if (!pending) return false;243 pendingReceiverSasConfirmation.delete(id);244 pending.resolve(ok && constantTimeEqual(pending.sas, sas));245 return true;246}247248async function runSourceSession(249 socket: PairingSocket,250 expectedToken: string,251 relayUrl: string,252 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,253): Promise<void> {254 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);255 let noise: NoiseNKSession | undefined;256 try {257 const hello = decodeJson<{ type?: unknown; token?: unknown }>(258 await socket.receive(),259 );260 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||261 !constantTimeEqual(hello.token, expectedToken)) {262 throw new Error("invalid_invitation");263 }264 socket.send(encodeJson({265 type: "PEACH_HELLO_ACK",266 ephemeralPublicKeyHex: bytesToHex(local.publicKey),267 }));268269 const session = await noiseNKResponder(270 local.privateKey,271 local.publicKey,272 async (bytes) => socket.send(bytes),273 () => socket.receive(),274 );275 noise = session;276 const channel: PairingTransport = {277 async send(plaintext) { socket.send(session.encrypt(plaintext)); },278 async receive() { return session.decrypt(await socket.receive()); },279 };280 const request = await receiveFrame(channel);281 if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {282 throw new Error("expected_ADOPT_REQUEST");283 }284285 const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {286 sourceIdentity: local.identity,287 pairState: {288 rendezvousSecretHex: bytesToHex(289 deriveRendezvousSecret(session.handshakeHash),290 ),291 relayUrl,292 },293 async rootKeyHex() {294 295 296 const value = getRootKeyHex();297 if (!value) throw new Error("vault_locked");298 return value;299 },300 async vaultId() {301 const value = getRootKeyHex();302 if (!value) throw new Error("vault_locked");303 const bytes = hexToBytes(value);304 try {305 return await computeVaultId(bytes);306 } finally {307 bytes.fill(0);308 }309 },310 async license() {311 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;312 },313 async applyLicense(license) {314 await applyTransferredLicense(license);315 },316 onConsent: (adoptRequest) => requestConsent(317 adoptRequest,318 derivePairingSAS(session.handshakeHash),319 crypto.randomUUID(),320 ),321 persistPendingHolder: persistPendingAdoptionHolder,322 clearPendingHolder: async (adoptRequest) => {323 const current = await readPendingAdoptionHolder();324 if (current?.request.device_id === adoptRequest.device_id) {325 await removePendingAdoptionHolder();326 }327 },328 async upsertPairedDevice(paired) {329 await storeAdoptedDevice(paired);330 },331 hasDeviceIdentityCollision: async (device) =>332 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),333 rotateSourceIdentity: async () => {334 await rotateDeviceId();335 return await buildLocalIdentity();336 },337 persistPendingIdentity: persistPendingAdoptionIdentity,338 async completePendingIdentity(paired: PairedDevice) {339 await completePendingAdoptionIdentity(paired);340 },341 snapshotCapable: () => true,342 };343 344 345 346 347 if (isAdoptRequest(request) && (await isPostReset(browserGenerationIo))) {348 push("ADOPTION_SOURCE_RESULT", {349 status: "error",350 error: "peer_protocol_outdated",351 });352 return;353 }354 const device = isAdoptRequestV2(request)355 ? await grantAdoptionV2(channel, request, sourceDeps)356 : await grantAdoption(channel, request, sourceDeps);357 if (!device) {358 push("ADOPTION_SOURCE_RESULT", {359 status: "error",360 error: "adoption_rejected",361 });362 return;363 }364 365 366 367 if (request.snapshot_capable === true) {368 try {369 const adapter = createSyncVaultAdapter();370 const entryManifest = await adapter.buildEntryManifest();371 const entryIds = Object.keys(entryManifest);372 const entries =373 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};374 const tombstones = await adapter.buildTombstoneManifest();375 let duressSlot: string | null = null;376 try {377 duressSlot = await exportDuressSlotForAdoption();378 } catch {379 380 }381 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);382383 const applied = await withTimeout(receiveFrame(channel), 15_000);384 if (isAdoptSnapshotApplied(applied)) {385 console.info(386 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,387 );388 }389390 if (adapter.buildOutgoingDeviceManifest) {391 const manifest = await adapter.buildOutgoingDeviceManifest();392 if (manifest) {393 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });394 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);395 }396 }397 } catch (snapshotError) {398 if (399 snapshotError instanceof Error &&400 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"401 ) {402 throw snapshotError;403 }404 console.info(405 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",406 snapshotError,407 );408 }409 }410 push("ADOPTION_SOURCE_RESULT", { status: "complete" });411 412 413 414 void triggerSyncIfReachable({ reason: "manual", force: true });415 schedulePostAdoptionSync();416 } catch (error) {417 push("ADOPTION_SOURCE_RESULT", {418 status: "error",419 error: error instanceof Error ? error.message : "adoption_failed",420 });421 } finally {422 clearTimeout(timeout);423 noise?.close();424 socket.close();425 pendingSourceSasConfirmation.clear();426 }427}428429430431432433434async function runReceiverSession(435 socket: PairingSocket,436 expectedToken: string,437 relayUrl: string,438 password: string,439 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },440): Promise<void> {441 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);442 443 const ephemeral = invitationEphemeral;444 const ephemeralPrivate = ephemeral.secretKey;445 const ephemeralPublic = ephemeral.publicKey;446 let noise: NoiseNKSession | undefined;447 let createdVault = false;448 let protocolError: string | undefined;449 try {450 const hello = decodeJson<{ type?: unknown; token?: unknown }>(451 await socket.receive(),452 );453 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||454 !constantTimeEqual(hello.token, expectedToken)) {455 throw new Error("invalid_invitation");456 }457 socket.send(encodeJson({458 type: "PEACH_HELLO_ACK",459 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),460 }));461462 const session = await noiseNKResponder(463 ephemeralPrivate,464 ephemeralPublic,465 async (bytes) => socket.send(bytes),466 () => socket.receive(),467 );468 noise = session;469 const channel: PairingTransport = {470 async send(plaintext) { socket.send(session.encrypt(plaintext)); },471 async receive() { return session.decrypt(await socket.receive()); },472 };473474 const deviceName = await getDeviceName();475476 const receiverDeps: AdoptionV2ReceiverDeps = {477 deviceName,478 async confirmSas() {479 const sas = derivePairingSAS(session.handshakeHash);480 const id = crypto.randomUUID();481 return waitForReceiverSasConfirmation(id, sas);482 },483 async createVaultFromRootKey(rootKeyHex) {484 485 486 487 488 489 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);490 createdVault = true;491 return { vaultId };492 },493 async applyLicense(license) {494 495 496 await applyTransferredLicense(license);497 },498 async license() {499 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;500 },501 async rotateLocalIdentity() {502 return await rotatePreVaultIdentity(password);503 },504 async hasSourceIdentityCollision(source) {505 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);506 },507 pairState: {508 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),509 relayUrl,510 },511 onError(error) {512 protocolError = error;513 },514 async localIdentity() {515 return await readPreVaultIdentity(password);516 },517 async validateSource() {},518 async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {519 await storeAdoptedDevice(paired);520 },521 };522 const adoptionResult = await requestAdoptionV2(channel, receiverDeps);523524 if (!adoptionResult) {525 if (createdVault) {526 await rollbackAdoptedVault();527 createdVault = false;528 }529 push("ADOPTION_SOURCE_RESULT", {530 status: "error",531 error: protocolError ?? "adoption_rejected",532 });533 return;534 }535 const { device, snapshotCapable } = adoptionResult;536 537 538 539 540 541 let snapshotApplied = false;542 if (snapshotCapable) try {543 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);544 const adapter = createSyncVaultAdapter();545 await adapter.applyEntries(snapshot.entries);546 await adapter.applyTombstones(snapshot.tombstones);547 548 549 if (snapshot.duressSlot) {550 try {551 await installAdoptedDuressSlot(snapshot.duressSlot);552 } catch (duressError) {553 console.info(554 "[Peach] adopted duress slot install failed (non-fatal):",555 duressError,556 );557 }558 }559 await sendFrame(channel, {560 type: "ADOPT_SNAPSHOT_APPLIED",561 entries_received: Object.keys(snapshot.entries).length,562 tombstones_received: Object.keys(snapshot.tombstones).length,563 });564565 566 567 try {568 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);569 if (570 isAdoptDeviceManifestFrame(manifestFrame) &&571 adapter.applyIncomingDeviceManifest &&572 typeof device.longTermPublicKeyHex === "string" &&573 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)574 ) {575 await adapter.applyIncomingDeviceManifest(576 manifestFrame.manifest,577 hexToBytes(device.longTermPublicKeyHex),578 );579 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });580 }581 } catch {582 583 }584585 await markLastSyncedAt(device.deviceId, Date.now());586 snapshotApplied = true;587 } catch (snapshotError) {588 console.info(589 "[Peach] inline snapshot failed, falling back to sync pipeline:",590 snapshotError,591 );592 }593594 595 596 597 598 599 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(600 () => undefined,601 );602 push("ADOPTION_SOURCE_RESULT", { status: "complete" });603 createdVault = false;604605 if (snapshotApplied) {606 607 608 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);609 } else {610 611 612 void triggerSyncIfReachable({ reason: "manual", force: true });613 schedulePostAdoptionSync();614 }615 } catch (error) {616 if (createdVault) {617 try {618 await rollbackAdoptedVault();619 createdVault = false;620 } catch (rollbackError) {621 console.error("[Peach] rollback failed:", rollbackError);622 }623 }624 console.error("[Peach] receiver session failed:", error);625 push("ADOPTION_SOURCE_RESULT", {626 status: "error",627 error: error instanceof Error ? error.message : "adoption_failed",628 });629 } finally {630 clearTimeout(timeout);631 noise?.close();632 socket.close();633 ephemeralPrivate.fill(0);634 ephemeralPublic.fill(0);635 }636}637638639640641642643async function applyTransferredLicense(license: string): Promise<void> {644 const activated = await activateLicense(license);645 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {646 throw new Error("license_transfer_failed");647 }648}649650651652653654655656657658659660function waitForReceiverSasConfirmation(661 id: string,662 sas: string,663): Promise<boolean> {664 return new Promise<boolean>((resolve) => {665 const timer = setTimeout(() => {666 pendingReceiverSasConfirmation.delete(id);667 resolve(false);668 }, SOURCE_WINDOW_MS);669 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {670 clearTimeout(timer);671 resolve(ok);672 } });673 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });674 });675}676677function waitForSourceSasConfirmation(678 id: string,679 request: { device_name: string },680 sas: string,681): Promise<boolean> {682 return new Promise<boolean>((resolve) => {683 const timer = setTimeout(() => {684 pendingSourceSasConfirmation.delete(id);685 resolve(false);686 }, SOURCE_WINDOW_MS);687 pendingSourceSasConfirmation.set(id, {688 resolve: (ok) => {689 clearTimeout(timer);690 resolve(ok);691 },692 });693 push("ADOPTION_SOURCE_SAS", {694 id,695 sas,696 receiverDeviceName: request.device_name,697 });698 });699}700701702703704705706707function requestConsent(708 request: { device_name: string },709 sas: string,710 id: string,711): Promise<boolean> {712 return waitForSourceSasConfirmation(id, request, sas);713}714715function clearState(): void {716 activeSocket = null;717 inFlight = false;718 pendingSourceSasConfirmation.clear();719 pendingReceiverSasConfirmation.clear();720}721722function constantTimeEqual(a: string, b: string): boolean {723 if (a.length !== b.length) return false;724 let diff = 0;725 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);726 return diff === 0;727}728729function encodeJson(value: unknown): Uint8Array {730 return new TextEncoder().encode(JSON.stringify(value));731}732733function decodeJson<T>(value: Uint8Array): T {734 return JSON.parse(new TextDecoder().decode(value)) as T;735}736737738739740741742743async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {744 const secret = paired.rendezvousSecretHex;745 const pub = { ...paired };746 delete pub.rendezvousSecretHex;747 748 749 await upsertPairedDeviceAndClearRevocation(pub);750 751 752 753 754 await onDirectV2PairingEstablished(browserGenerationIo, true);755 if (!isValidPairSecret(secret)) return;756 const masterKeyHex = getRootKeyHex();757 if (!masterKeyHex) return;758 const masterKey = hexToBytes(masterKeyHex);759 try {760 await setPairSecret(paired.deviceId, secret, masterKey);761 } finally {762 masterKey.fill(0);763 }764}765766function hexToBytes(hex: string): Uint8Array {767 const out = new Uint8Array(hex.length / 2);768 for (let i = 0; i < out.length; i++) {769 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);770 }771 return out;772}