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 }>();8788export function isRelayAdoptionSourceInFlight(): boolean {89 return inFlight;90}919293949596export async function startRelayAdoptionSource(): Promise<{97 invitation: string;98}> {99 if (inFlight) throw new Error("adoption_source_in_progress");100 if (!isUnlocked()) throw new Error("vault_locked");101 inFlight = true;102103 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;104 try {105 const settings = await getSyncSettings();106 const relayUrl = relayInForce(settings);107 local = await getLocalPairingIdentity();108 const token = generatePairingRendezvousToken();109 110 111 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));112 const invitation = parsePairingInvitation(encoded);113 if (!invitation) throw new Error("invitation_generation_failed");114115 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));116 activeSocket = socket;117 const readyLocal = local;118 void runSourceSession(119 socket,120 token,121 relayUrl,122 readyLocal,123 ).finally(() => {124 readyLocal.privateKey.fill(0);125 readyLocal.publicKey.fill(0);126 clearState();127 });128 return { invitation: encoded };129 } catch (error) {130 local?.privateKey.fill(0);131 local?.publicKey.fill(0);132 clearState();133 throw error;134 }135}136137export function cancelRelayAdoptionSource(): void {138 activeSocket?.close();139 clearState();140}141142143144145146147148149150151152153export async function startRelayAdoptionReceiver(154 password: string,155): Promise<{ invitation: string }> {156 if (inFlight) throw new Error("adoption_receiver_in_progress");157 if (typeof password !== "string" || password.length === 0) {158 throw new Error("ADOPT_INVALID_PASSWORD");159 }160 inFlight = true;161 try {162 const settings = await getSyncSettings();163 const relayUrl = relayInForce(settings);164 const token = generatePairingRendezvousToken();165 const ephemeral = generateKeyPair();166 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));167 const invitation = parsePairingInvitation(encoded);168 if (!invitation) throw new Error("invitation_generation_failed");169 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));170 activeSocket = socket;171 void runReceiverSession(172 socket,173 token,174 relayUrl,175 password,176 ephemeral,177 ).finally(() => {178 ephemeral.secretKey.fill(0);179 ephemeral.publicKey.fill(0);180 clearState();181 });182 return { invitation: encoded };183 } catch (error) {184 clearState();185 throw error;186 }187}188189190191192193194195196export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {197 const pending = pendingSourceSasConfirmation.get(id);198 if (!pending) return false;199 pendingSourceSasConfirmation.delete(id);200 pending.resolve(ok);201 return true;202}203204async function runSourceSession(205 socket: PairingSocket,206 expectedToken: string,207 relayUrl: string,208 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,209): Promise<void> {210 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);211 let noise: NoiseNKSession | undefined;212 try {213 const hello = decodeJson<{ type?: unknown; token?: unknown }>(214 await socket.receive(),215 );216 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||217 !constantTimeEqual(hello.token, expectedToken)) {218 throw new Error("invalid_invitation");219 }220 socket.send(encodeJson({221 type: "PEACH_HELLO_ACK",222 ephemeralPublicKeyHex: bytesToHex(local.publicKey),223 }));224225 const session = await noiseNKResponder(226 local.privateKey,227 local.publicKey,228 async (bytes) => socket.send(bytes),229 () => socket.receive(),230 );231 noise = session;232 const channel: PairingTransport = {233 async send(plaintext) { socket.send(session.encrypt(plaintext)); },234 async receive() { return session.decrypt(await socket.receive()); },235 };236 const request = await receiveFrame(channel);237 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");238239 const device = await grantAdoption(channel, request, {240 sourceIdentity: local.identity,241 pairState: {242 rendezvousSecretHex: bytesToHex(243 deriveRendezvousSecret(session.handshakeHash),244 ),245 relayUrl,246 },247 async rootKeyHex() {248 249 250 const value = getRootKeyHex();251 if (!value) throw new Error("vault_locked");252 return value;253 },254 async vaultId() {255 const value = getRootKeyHex();256 if (!value) throw new Error("vault_locked");257 const bytes = hexToBytes(value);258 try {259 return await computeVaultId(bytes);260 } finally {261 bytes.fill(0);262 }263 },264 async license() {265 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;266 },267 async applyLicense(license) {268 await applyTransferredLicense(license);269 },270 onConsent: (adoptRequest) => requestConsent(271 adoptRequest,272 derivePairingSAS(session.handshakeHash),273 crypto.randomUUID(),274 ),275 upsertPairedDevice: storeAdoptedDevice,276 });277 if (!device) {278 push("ADOPTION_SOURCE_RESULT", {279 status: "error",280 error: "adoption_rejected",281 });282 return;283 }284 285 286 287 if (request.snapshot_capable === true) {288 try {289 const adapter = createSyncVaultAdapter();290 const entryManifest = await adapter.buildEntryManifest();291 const entryIds = Object.keys(entryManifest);292 const entries =293 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};294 const tombstones = await adapter.buildTombstoneManifest();295 let duressSlot: string | null = null;296 try {297 duressSlot = await exportDuressSlotForAdoption();298 } catch {299 300 }301 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);302303 const applied = await withTimeout(receiveFrame(channel), 15_000);304 if (isAdoptSnapshotApplied(applied)) {305 console.info(306 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,307 );308 }309310 if (adapter.buildOutgoingDeviceManifest) {311 const manifest = await adapter.buildOutgoingDeviceManifest();312 if (manifest) {313 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });314 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);315 }316 }317 } catch (snapshotError) {318 if (319 snapshotError instanceof Error &&320 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"321 ) {322 throw snapshotError;323 }324 console.info(325 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",326 snapshotError,327 );328 }329 }330 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });331 332 333 334 void triggerSyncIfReachable({ reason: "manual", force: true });335 schedulePostAdoptionSync();336 } catch (error) {337 push("ADOPTION_SOURCE_RESULT", {338 status: "error",339 error: error instanceof Error ? error.message : "adoption_failed",340 });341 } finally {342 clearTimeout(timeout);343 noise?.close();344 socket.close();345 pendingSourceSasConfirmation.clear();346 }347}348349350351352353354async function runReceiverSession(355 socket: PairingSocket,356 expectedToken: string,357 relayUrl: string,358 password: string,359 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },360): Promise<void> {361 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);362 363 const ephemeral = invitationEphemeral;364 const ephemeralPrivate = ephemeral.secretKey;365 const ephemeralPublic = ephemeral.publicKey;366 let noise: NoiseNKSession | undefined;367 let createdVault = false;368 let protocolError: string | undefined;369 try {370 const hello = decodeJson<{ type?: unknown; token?: unknown }>(371 await socket.receive(),372 );373 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||374 !constantTimeEqual(hello.token, expectedToken)) {375 throw new Error("invalid_invitation");376 }377 socket.send(encodeJson({378 type: "PEACH_HELLO_ACK",379 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),380 }));381382 const session = await noiseNKResponder(383 ephemeralPrivate,384 ephemeralPublic,385 async (bytes) => socket.send(bytes),386 () => socket.receive(),387 );388 noise = session;389 const channel: PairingTransport = {390 async send(plaintext) { socket.send(session.encrypt(plaintext)); },391 async receive() { return session.decrypt(await socket.receive()); },392 };393394 const deviceName = await getDeviceName();395396 const adoptionResult = await requestAdoption(channel, {397 deviceName,398 generateDeviceId: () => crypto.randomUUID(),399 400 401 402 403 404 405 async confirmSas() {406 push("ADOPTION_SOURCE_SAS", {407 sas: derivePairingSAS(session.handshakeHash),408 displayOnly: true,409 });410 return true;411 },412 async createVaultFromRootKey(rootKeyHex) {413 414 415 416 417 418 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);419 createdVault = true;420 return { vaultId };421 },422 async applyLicense(license) {423 424 425 await applyTransferredLicense(license);426 },427 async license() {428 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;429 },430 async deriveLocalIdentity() {431 432 const masterKeyHex = getRootKeyHex();433 if (!masterKeyHex) throw new Error("identity_unavailable");434 const masterKey = hexToBytes(masterKeyHex);435 try {436 return await buildLocalIdentity(masterKey);437 } finally {438 masterKey.fill(0);439 }440 },441 async upsertPairedDevice(paired) {442 443 await storeAdoptedDevice(paired);444 },445 pairState: {446 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),447 relayUrl,448 },449 onError(error) {450 protocolError = error;451 },452 });453454 if (!adoptionResult) {455 if (createdVault) {456 await rollbackAdoptedVault();457 createdVault = false;458 }459 push("ADOPTION_SOURCE_RESULT", {460 status: "error",461 error: protocolError ?? "adoption_rejected",462 });463 return;464 }465 const { device, snapshotCapable } = adoptionResult;466 467 468 469 470 471 let snapshotApplied = false;472 if (snapshotCapable) try {473 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);474 const adapter = createSyncVaultAdapter();475 await adapter.applyEntries(snapshot.entries);476 await adapter.applyTombstones(snapshot.tombstones);477 478 479 if (snapshot.duressSlot) {480 try {481 await installAdoptedDuressSlot(snapshot.duressSlot);482 } catch (duressError) {483 console.info(484 "[Peach] adopted duress slot install failed (non-fatal):",485 duressError,486 );487 }488 }489 await sendFrame(channel, {490 type: "ADOPT_SNAPSHOT_APPLIED",491 entries_received: Object.keys(snapshot.entries).length,492 tombstones_received: Object.keys(snapshot.tombstones).length,493 });494495 496 497 try {498 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);499 if (500 isAdoptDeviceManifestFrame(manifestFrame) &&501 adapter.applyIncomingDeviceManifest &&502 typeof device.longTermPublicKeyHex === "string" &&503 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)504 ) {505 await adapter.applyIncomingDeviceManifest(506 manifestFrame.manifest,507 hexToBytes(device.longTermPublicKeyHex),508 );509 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });510 }511 } catch {512 513 }514515 await markLastSyncedAt(device.deviceId, Date.now());516 snapshotApplied = true;517 } catch (snapshotError) {518 console.info(519 "[Peach] inline snapshot failed, falling back to sync pipeline:",520 snapshotError,521 );522 }523524 525 526 527 528 529 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(530 () => undefined,531 );532 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });533 createdVault = false;534535 if (snapshotApplied) {536 537 538 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);539 } else {540 541 542 void triggerSyncIfReachable({ reason: "manual", force: true });543 schedulePostAdoptionSync();544 }545 } catch (error) {546 if (createdVault) {547 try {548 await rollbackAdoptedVault();549 createdVault = false;550 } catch (rollbackError) {551 console.error("[Peach] rollback failed:", rollbackError);552 }553 }554 console.error("[Peach] receiver session failed:", error);555 push("ADOPTION_SOURCE_RESULT", {556 status: "error",557 error: error instanceof Error ? error.message : "adoption_failed",558 });559 } finally {560 clearTimeout(timeout);561 noise?.close();562 socket.close();563 ephemeralPrivate.fill(0);564 ephemeralPublic.fill(0);565 }566}567568569570571572573async function applyTransferredLicense(license: string): Promise<void> {574 const activated = await activateLicense(license);575 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {576 throw new Error("license_transfer_failed");577 }578}579580581582583584585586587588589590function waitForSourceSasConfirmation(591 id: string,592 request: AdoptRequest,593 sas: string,594): Promise<boolean> {595 return new Promise<boolean>((resolve) => {596 const timer = setTimeout(() => {597 pendingSourceSasConfirmation.delete(id);598 resolve(false);599 }, SOURCE_WINDOW_MS);600 pendingSourceSasConfirmation.set(id, {601 resolve: (ok) => {602 clearTimeout(timer);603 resolve(ok);604 },605 });606 push("ADOPTION_SOURCE_SAS", {607 id,608 sas,609 receiverDeviceName: request.device_name,610 });611 });612}613614615616617618619620function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {621 return waitForSourceSasConfirmation(id, request, sas);622}623624function clearState(): void {625 activeSocket = null;626 inFlight = false;627 pendingSourceSasConfirmation.clear();628}629630function constantTimeEqual(a: string, b: string): boolean {631 if (a.length !== b.length) return false;632 let diff = 0;633 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);634 return diff === 0;635}636637function encodeJson(value: unknown): Uint8Array {638 return new TextEncoder().encode(JSON.stringify(value));639}640641function decodeJson<T>(value: Uint8Array): T {642 return JSON.parse(new TextDecoder().decode(value)) as T;643}644645646647648649650651async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {652 const secret = paired.rendezvousSecretHex;653 const pub = { ...paired };654 delete pub.rendezvousSecretHex;655 await upsertPairedDevice(pub);656 if (!isValidPairSecret(secret)) return;657 const masterKeyHex = getRootKeyHex();658 if (!masterKeyHex) return;659 const masterKey = hexToBytes(masterKeyHex);660 try {661 await setPairSecret(paired.deviceId, secret, masterKey);662 } finally {663 masterKey.fill(0);664 }665}666667function hexToBytes(hex: string): Uint8Array {668 const out = new Uint8Array(hex.length / 2);669 for (let i = 0; i < out.length; i++) {670 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);671 }672 return out;673}