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 {44 hasAdoptionIdentityConflict,45 markLastSyncedAt,46 upsertPairedDeviceAndClearRevocation,47} from "./paired-devices";48import { getSyncSettings, relayInForce } from "./sync-settings";49import { activateLicense, getLicenseKey, isProUser } from "./license";50import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";51import { deferAutoLockForOperation } from "./alarms";52import { computeVaultId } from "../shared/sync-identity";53import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";54import { storageRemove, storageSet } from "../shared/storage";55import {56 persistPendingAdoptionHolder,57 readPendingAdoptionHolder,58 removePendingAdoptionHolder,59} from "./pending-adoption-holder";60import { isValidPairSecret, setPairSecret } from "./pair-secrets";61import { createSyncVaultAdapter } from "./sync-vault-adapter";62import { triggerSyncIfReachable } from "./sync-trigger";63import { schedulePostAdoptionSync } from "./sync-alarm";6465const SOURCE_WINDOW_MS = 5 * 60 * 1000;6667function push(type: string, payload: unknown): void {68 try {69 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;70 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {71 (maybe as Promise<unknown>).catch(() => undefined);72 }73 } catch {74 75 }76}777879function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {80 return new Promise<T>((resolve, reject) => {81 const timer = setTimeout(() => reject(new Error("timeout")), ms);82 promise.then(83 (value) => {84 clearTimeout(timer);85 resolve(value);86 },87 (error) => {88 clearTimeout(timer);89 reject(error);90 },91 );92 });93}9495let inFlight = false;96let activeSocket: PairingSocket | null = null;979899100101const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();102const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();103104export function isRelayAdoptionSourceInFlight(): boolean {105 return inFlight;106}107108109110111112export async function startRelayAdoptionSource(): Promise<{113 invitation: string;114}> {115 if (inFlight) throw new Error("adoption_source_in_progress");116 if (!isUnlocked()) throw new Error("vault_locked");117 inFlight = true;118 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);119120 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;121 try {122 const settings = await getSyncSettings();123 const relayUrl = relayInForce(settings);124 local = await getLocalPairingIdentity();125 const token = generatePairingRendezvousToken();126 127 128 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));129 const invitation = parsePairingInvitation(encoded);130 if (!invitation) throw new Error("invitation_generation_failed");131132 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));133 activeSocket = socket;134 const readyLocal = local;135 void runSourceSession(136 socket,137 token,138 relayUrl,139 readyLocal,140 ).finally(() => {141 readyLocal.privateKey.fill(0);142 readyLocal.publicKey.fill(0);143 clearState();144 releaseAutoLock();145 });146 return { invitation: encoded };147 } catch (error) {148 local?.privateKey.fill(0);149 local?.publicKey.fill(0);150 clearState();151 releaseAutoLock();152 throw error;153 }154}155156export function cancelRelayAdoptionSource(): void {157 activeSocket?.close();158 clearState();159}160161162163164165166167168169170171172export async function startRelayAdoptionReceiver(173 password: string,174): Promise<{ invitation: string }> {175 if (inFlight) throw new Error("adoption_receiver_in_progress");176 if (typeof password !== "string" || password.length === 0) {177 throw new Error("ADOPT_INVALID_PASSWORD");178 }179 inFlight = true;180 try {181 const settings = await getSyncSettings();182 const relayUrl = relayInForce(settings);183 const token = generatePairingRendezvousToken();184 const ephemeral = generateKeyPair();185 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));186 const invitation = parsePairingInvitation(encoded);187 if (!invitation) throw new Error("invitation_generation_failed");188 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));189 activeSocket = socket;190 void runReceiverSession(191 socket,192 token,193 relayUrl,194 password,195 ephemeral,196 ).finally(() => {197 ephemeral.secretKey.fill(0);198 ephemeral.publicKey.fill(0);199 clearState();200 });201 return { invitation: encoded };202 } catch (error) {203 clearState();204 throw error;205 }206}207208209210211212213214215export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {216 const pending = pendingSourceSasConfirmation.get(id);217 if (!pending) return false;218 pendingSourceSasConfirmation.delete(id);219 pending.resolve(ok);220 return true;221}222223export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {224 const pending = pendingReceiverSasConfirmation.get(id);225 if (!pending) return false;226 pendingReceiverSasConfirmation.delete(id);227 pending.resolve(ok && constantTimeEqual(pending.sas, sas));228 return true;229}230231async function runSourceSession(232 socket: PairingSocket,233 expectedToken: string,234 relayUrl: string,235 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,236): Promise<void> {237 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);238 let noise: NoiseNKSession | undefined;239 try {240 const hello = decodeJson<{ type?: unknown; token?: unknown }>(241 await socket.receive(),242 );243 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||244 !constantTimeEqual(hello.token, expectedToken)) {245 throw new Error("invalid_invitation");246 }247 socket.send(encodeJson({248 type: "PEACH_HELLO_ACK",249 ephemeralPublicKeyHex: bytesToHex(local.publicKey),250 }));251252 const session = await noiseNKResponder(253 local.privateKey,254 local.publicKey,255 async (bytes) => socket.send(bytes),256 () => socket.receive(),257 );258 noise = session;259 const channel: PairingTransport = {260 async send(plaintext) { socket.send(session.encrypt(plaintext)); },261 async receive() { return session.decrypt(await socket.receive()); },262 };263 const request = await receiveFrame(channel);264 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");265266 const device = await grantAdoption(channel, request, {267 sourceIdentity: local.identity,268 pairState: {269 rendezvousSecretHex: bytesToHex(270 deriveRendezvousSecret(session.handshakeHash),271 ),272 relayUrl,273 },274 async rootKeyHex() {275 276 277 const value = getRootKeyHex();278 if (!value) throw new Error("vault_locked");279 return value;280 },281 async vaultId() {282 const value = getRootKeyHex();283 if (!value) throw new Error("vault_locked");284 const bytes = hexToBytes(value);285 try {286 return await computeVaultId(bytes);287 } finally {288 bytes.fill(0);289 }290 },291 async license() {292 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;293 },294 async applyLicense(license) {295 await applyTransferredLicense(license);296 },297 onConsent: (adoptRequest) => requestConsent(298 adoptRequest,299 derivePairingSAS(session.handshakeHash),300 crypto.randomUUID(),301 ),302 persistPendingHolder: persistPendingAdoptionHolder,303 clearPendingHolder: async (adoptRequest) => {304 const current = await readPendingAdoptionHolder();305 if (current?.request.device_id === adoptRequest.device_id) {306 await removePendingAdoptionHolder();307 }308 },309 async upsertPairedDevice(paired) {310 await storeAdoptedDevice(paired);311 },312 hasDeviceIdentityCollision: async (device) =>313 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),314 rotateSourceIdentity: async () => {315 await rotateDeviceId();316 return await buildLocalIdentity();317 },318 });319 if (!device) {320 push("ADOPTION_SOURCE_RESULT", {321 status: "error",322 error: "adoption_rejected",323 });324 return;325 }326 327 328 329 if (request.snapshot_capable === true) {330 try {331 const adapter = createSyncVaultAdapter();332 const entryManifest = await adapter.buildEntryManifest();333 const entryIds = Object.keys(entryManifest);334 const entries =335 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};336 const tombstones = await adapter.buildTombstoneManifest();337 let duressSlot: string | null = null;338 try {339 duressSlot = await exportDuressSlotForAdoption();340 } catch {341 342 }343 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);344345 const applied = await withTimeout(receiveFrame(channel), 15_000);346 if (isAdoptSnapshotApplied(applied)) {347 console.info(348 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,349 );350 }351352 if (adapter.buildOutgoingDeviceManifest) {353 const manifest = await adapter.buildOutgoingDeviceManifest();354 if (manifest) {355 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });356 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);357 }358 }359 } catch (snapshotError) {360 if (361 snapshotError instanceof Error &&362 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"363 ) {364 throw snapshotError;365 }366 console.info(367 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",368 snapshotError,369 );370 }371 }372 push("ADOPTION_SOURCE_RESULT", { status: "complete" });373 374 375 376 void triggerSyncIfReachable({ reason: "manual", force: true });377 schedulePostAdoptionSync();378 } catch (error) {379 push("ADOPTION_SOURCE_RESULT", {380 status: "error",381 error: error instanceof Error ? error.message : "adoption_failed",382 });383 } finally {384 clearTimeout(timeout);385 noise?.close();386 socket.close();387 pendingSourceSasConfirmation.clear();388 }389}390391392393394395396async function runReceiverSession(397 socket: PairingSocket,398 expectedToken: string,399 relayUrl: string,400 password: string,401 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },402): Promise<void> {403 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);404 405 const ephemeral = invitationEphemeral;406 const ephemeralPrivate = ephemeral.secretKey;407 const ephemeralPublic = ephemeral.publicKey;408 let noise: NoiseNKSession | undefined;409 let createdVault = false;410 let protocolError: string | undefined;411 try {412 const hello = decodeJson<{ type?: unknown; token?: unknown }>(413 await socket.receive(),414 );415 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||416 !constantTimeEqual(hello.token, expectedToken)) {417 throw new Error("invalid_invitation");418 }419 socket.send(encodeJson({420 type: "PEACH_HELLO_ACK",421 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),422 }));423424 const session = await noiseNKResponder(425 ephemeralPrivate,426 ephemeralPublic,427 async (bytes) => socket.send(bytes),428 () => socket.receive(),429 );430 noise = session;431 const channel: PairingTransport = {432 async send(plaintext) { socket.send(session.encrypt(plaintext)); },433 async receive() { return session.decrypt(await socket.receive()); },434 };435436 const deviceName = await getDeviceName();437438 const adoptionResult = await requestAdoption(channel, {439 deviceName,440 generateDeviceId: () => crypto.randomUUID(),441 async confirmSas() {442 const sas = derivePairingSAS(session.handshakeHash);443 const id = crypto.randomUUID();444 return waitForReceiverSasConfirmation(id, sas);445 },446 async createVaultFromRootKey(rootKeyHex) {447 448 449 450 451 452 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);453 createdVault = true;454 return { vaultId };455 },456 async applyLicense(license) {457 458 459 await applyTransferredLicense(license);460 },461 async license() {462 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;463 },464 async deriveLocalIdentity() {465 466 return await buildLocalIdentity();467 },468 async rotateLocalIdentity() {469 await rotateDeviceId();470 return await buildLocalIdentity();471 },472 async upsertPairedDevice(paired) {473 474 await storeAdoptedDevice(paired);475 },476 async hasSourceIdentityCollision(source) {477 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);478 },479 pairState: {480 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),481 relayUrl,482 },483 onError(error) {484 protocolError = error;485 },486 });487488 if (!adoptionResult) {489 if (createdVault) {490 await rollbackAdoptedVault();491 createdVault = false;492 }493 push("ADOPTION_SOURCE_RESULT", {494 status: "error",495 error: protocolError ?? "adoption_rejected",496 });497 return;498 }499 const { device, snapshotCapable } = adoptionResult;500 501 502 503 504 505 let snapshotApplied = false;506 if (snapshotCapable) try {507 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);508 const adapter = createSyncVaultAdapter();509 await adapter.applyEntries(snapshot.entries);510 await adapter.applyTombstones(snapshot.tombstones);511 512 513 if (snapshot.duressSlot) {514 try {515 await installAdoptedDuressSlot(snapshot.duressSlot);516 } catch (duressError) {517 console.info(518 "[Peach] adopted duress slot install failed (non-fatal):",519 duressError,520 );521 }522 }523 await sendFrame(channel, {524 type: "ADOPT_SNAPSHOT_APPLIED",525 entries_received: Object.keys(snapshot.entries).length,526 tombstones_received: Object.keys(snapshot.tombstones).length,527 });528529 530 531 try {532 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);533 if (534 isAdoptDeviceManifestFrame(manifestFrame) &&535 adapter.applyIncomingDeviceManifest &&536 typeof device.longTermPublicKeyHex === "string" &&537 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)538 ) {539 await adapter.applyIncomingDeviceManifest(540 manifestFrame.manifest,541 hexToBytes(device.longTermPublicKeyHex),542 );543 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });544 }545 } catch {546 547 }548549 await markLastSyncedAt(device.deviceId, Date.now());550 snapshotApplied = true;551 } catch (snapshotError) {552 console.info(553 "[Peach] inline snapshot failed, falling back to sync pipeline:",554 snapshotError,555 );556 }557558 559 560 561 562 563 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(564 () => undefined,565 );566 push("ADOPTION_SOURCE_RESULT", { status: "complete" });567 createdVault = false;568569 if (snapshotApplied) {570 571 572 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);573 } else {574 575 576 void triggerSyncIfReachable({ reason: "manual", force: true });577 schedulePostAdoptionSync();578 }579 } catch (error) {580 if (createdVault) {581 try {582 await rollbackAdoptedVault();583 createdVault = false;584 } catch (rollbackError) {585 console.error("[Peach] rollback failed:", rollbackError);586 }587 }588 console.error("[Peach] receiver session failed:", error);589 push("ADOPTION_SOURCE_RESULT", {590 status: "error",591 error: error instanceof Error ? error.message : "adoption_failed",592 });593 } finally {594 clearTimeout(timeout);595 noise?.close();596 socket.close();597 ephemeralPrivate.fill(0);598 ephemeralPublic.fill(0);599 }600}601602603604605606607async function applyTransferredLicense(license: string): Promise<void> {608 const activated = await activateLicense(license);609 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {610 throw new Error("license_transfer_failed");611 }612}613614615616617618619620621622623624function waitForReceiverSasConfirmation(625 id: string,626 sas: string,627): Promise<boolean> {628 return new Promise<boolean>((resolve) => {629 const timer = setTimeout(() => {630 pendingReceiverSasConfirmation.delete(id);631 resolve(false);632 }, SOURCE_WINDOW_MS);633 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {634 clearTimeout(timer);635 resolve(ok);636 } });637 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });638 });639}640641function waitForSourceSasConfirmation(642 id: string,643 request: AdoptRequest,644 sas: string,645): Promise<boolean> {646 return new Promise<boolean>((resolve) => {647 const timer = setTimeout(() => {648 pendingSourceSasConfirmation.delete(id);649 resolve(false);650 }, SOURCE_WINDOW_MS);651 pendingSourceSasConfirmation.set(id, {652 resolve: (ok) => {653 clearTimeout(timer);654 resolve(ok);655 },656 });657 push("ADOPTION_SOURCE_SAS", {658 id,659 sas,660 receiverDeviceName: request.device_name,661 });662 });663}664665666667668669670671function requestConsent(672 request: AdoptRequest,673 sas: string,674 id: string,675): Promise<boolean> {676 return waitForSourceSasConfirmation(id, request, sas);677}678679function clearState(): void {680 activeSocket = null;681 inFlight = false;682 pendingSourceSasConfirmation.clear();683 pendingReceiverSasConfirmation.clear();684}685686function constantTimeEqual(a: string, b: string): boolean {687 if (a.length !== b.length) return false;688 let diff = 0;689 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);690 return diff === 0;691}692693function encodeJson(value: unknown): Uint8Array {694 return new TextEncoder().encode(JSON.stringify(value));695}696697function decodeJson<T>(value: Uint8Array): T {698 return JSON.parse(new TextDecoder().decode(value)) as T;699}700701702703704705706707async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {708 const secret = paired.rendezvousSecretHex;709 const pub = { ...paired };710 delete pub.rendezvousSecretHex;711 712 713 await upsertPairedDeviceAndClearRevocation(pub);714 if (!isValidPairSecret(secret)) return;715 const masterKeyHex = getRootKeyHex();716 if (!masterKeyHex) return;717 const masterKey = hexToBytes(masterKeyHex);718 try {719 await setPairSecret(paired.deviceId, secret, masterKey);720 } finally {721 masterKey.fill(0);722 }723}724725function hexToBytes(hex: string): Uint8Array {726 const out = new Uint8Array(hex.length / 2);727 for (let i = 0; i < out.length; i++) {728 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);729 }730 return out;731}