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";80import type { LocalIdentity } from "../shared/pairing-handshake";8182const SOURCE_WINDOW_MS = 5 * 60 * 1000;8384function push(type: string, payload: unknown): void {85 try {86 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;87 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {88 (maybe as Promise<unknown>).catch(() => undefined);89 }90 } catch {91 92 }93}949596function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {97 return new Promise<T>((resolve, reject) => {98 const timer = setTimeout(() => reject(new Error("timeout")), ms);99 promise.then(100 (value) => {101 clearTimeout(timer);102 resolve(value);103 },104 (error) => {105 clearTimeout(timer);106 reject(error);107 },108 );109 });110}111112let inFlight = false;113let activeSocket: PairingSocket | null = null;114115116117118const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();119const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();120121export function isRelayAdoptionSourceInFlight(): boolean {122 return inFlight;123}124125126127128129export async function startRelayAdoptionSource(): Promise<{130 invitation: string;131}> {132 if (inFlight) throw new Error("adoption_source_in_progress");133 if (!isUnlocked()) throw new Error("vault_locked");134 inFlight = true;135 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);136137 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;138 try {139 const settings = await getSyncSettings();140 const relayUrl = relayInForce(settings);141 local = await getLocalPairingIdentity();142 const token = generatePairingRendezvousToken();143 144 145 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));146 const invitation = parsePairingInvitation(encoded);147 if (!invitation) throw new Error("invitation_generation_failed");148149 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));150 activeSocket = socket;151 const readyLocal = local;152 void runSourceSession(153 socket,154 token,155 relayUrl,156 readyLocal,157 ).finally(() => {158 readyLocal.privateKey.fill(0);159 readyLocal.publicKey.fill(0);160 clearState();161 releaseAutoLock();162 });163 return { invitation: encoded };164 } catch (error) {165 local?.privateKey.fill(0);166 local?.publicKey.fill(0);167 clearState();168 releaseAutoLock();169 throw error;170 }171}172173export function cancelRelayAdoptionSource(): void {174 activeSocket?.close();175 clearState();176}177178179180181182183184185186187188189export async function startRelayAdoptionReceiver(190 password: string,191): Promise<{ invitation: string }> {192 if (inFlight) throw new Error("adoption_receiver_in_progress");193 if (typeof password !== "string" || password.length === 0) {194 throw new Error("ADOPT_INVALID_PASSWORD");195 }196 inFlight = true;197 try {198 await preparePreVaultIdentity(password);199 const settings = await getSyncSettings();200 const relayUrl = relayInForce(settings);201 const token = generatePairingRendezvousToken();202 const ephemeral = generateKeyPair();203 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));204 const invitation = parsePairingInvitation(encoded);205 if (!invitation) throw new Error("invitation_generation_failed");206 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));207 activeSocket = socket;208 void runReceiverSession(209 socket,210 token,211 relayUrl,212 password,213 ephemeral,214 ).finally(() => {215 ephemeral.secretKey.fill(0);216 ephemeral.publicKey.fill(0);217 clearState();218 });219 return { invitation: encoded };220 } catch (error) {221 clearState();222 throw error;223 }224}225226227228229230231232233export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {234 const pending = pendingSourceSasConfirmation.get(id);235 if (!pending) return false;236 pendingSourceSasConfirmation.delete(id);237 pending.resolve(ok);238 return true;239}240241export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {242 const pending = pendingReceiverSasConfirmation.get(id);243 if (!pending) return false;244 pendingReceiverSasConfirmation.delete(id);245 pending.resolve(ok && constantTimeEqual(pending.sas, sas));246 return true;247}248249async function runSourceSession(250 socket: PairingSocket,251 expectedToken: string,252 relayUrl: string,253 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,254): Promise<void> {255 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);256 let noise: NoiseNKSession | undefined;257 try {258 const hello = decodeJson<{ type?: unknown; token?: unknown }>(259 await socket.receive(),260 );261 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||262 !constantTimeEqual(hello.token, expectedToken)) {263 throw new Error("invalid_invitation");264 }265 socket.send(encodeJson({266 type: "PEACH_HELLO_ACK",267 ephemeralPublicKeyHex: bytesToHex(local.publicKey),268 }));269270 const session = await noiseNKResponder(271 local.privateKey,272 local.publicKey,273 async (bytes) => socket.send(bytes),274 () => socket.receive(),275 );276 noise = session;277 const channel: PairingTransport = {278 async send(plaintext) { socket.send(session.encrypt(plaintext)); },279 async receive() { return session.decrypt(await socket.receive()); },280 };281 const request = await receiveFrame(channel);282 if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {283 throw new Error("expected_ADOPT_REQUEST");284 }285286 const sourceDeps = buildSourceSessionDeps({287 identity: local.identity,288 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),289 relayUrl,290 sas: derivePairingSAS(session.handshakeHash),291 });292 293 294 295 296 if (isAdoptRequest(request) && (await isPostReset(browserGenerationIo))) {297 push("ADOPTION_SOURCE_RESULT", {298 status: "error",299 error: "peer_protocol_outdated",300 });301 return;302 }303 const device = isAdoptRequestV2(request)304 ? await grantAdoptionV2(channel, request, sourceDeps)305 : await grantAdoption(channel, request, sourceDeps);306 if (!device) {307 push("ADOPTION_SOURCE_RESULT", {308 status: "error",309 error: "adoption_rejected",310 });311 return;312 }313 314 315 316 if (request.snapshot_capable === true) {317 try {318 const adapter = createSyncVaultAdapter();319 const entryManifest = await adapter.buildEntryManifest();320 const entryIds = Object.keys(entryManifest);321 const entries =322 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};323 const tombstones = await adapter.buildTombstoneManifest();324 let duressSlot: string | null = null;325 try {326 duressSlot = await exportDuressSlotForAdoption();327 } catch {328 329 }330 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);331332 const applied = await withTimeout(receiveFrame(channel), 15_000);333 if (isAdoptSnapshotApplied(applied)) {334 console.info(335 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,336 );337 }338339 if (adapter.buildOutgoingDeviceManifest) {340 const manifest = await adapter.buildOutgoingDeviceManifest();341 if (manifest) {342 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });343 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);344 }345 }346 } catch (snapshotError) {347 if (348 snapshotError instanceof Error &&349 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"350 ) {351 throw snapshotError;352 }353 console.info(354 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",355 snapshotError,356 );357 }358 }359 push("ADOPTION_SOURCE_RESULT", { status: "complete" });360 361 362 363 void triggerSyncIfReachable({ reason: "manual", force: true });364 schedulePostAdoptionSync();365 } catch (error) {366 push("ADOPTION_SOURCE_RESULT", {367 status: "error",368 error: error instanceof Error ? error.message : "adoption_failed",369 });370 } finally {371 clearTimeout(timeout);372 noise?.close();373 socket.close();374 pendingSourceSasConfirmation.clear();375 }376}377378379380381382383async function runReceiverSession(384 socket: PairingSocket,385 expectedToken: string,386 relayUrl: string,387 password: string,388 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },389): Promise<void> {390 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);391 392 const ephemeral = invitationEphemeral;393 const ephemeralPrivate = ephemeral.secretKey;394 const ephemeralPublic = ephemeral.publicKey;395 let noise: NoiseNKSession | undefined;396 let createdVault = false;397 let protocolError: string | undefined;398 try {399 const hello = decodeJson<{ type?: unknown; token?: unknown }>(400 await socket.receive(),401 );402 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||403 !constantTimeEqual(hello.token, expectedToken)) {404 throw new Error("invalid_invitation");405 }406 socket.send(encodeJson({407 type: "PEACH_HELLO_ACK",408 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),409 }));410411 const session = await noiseNKResponder(412 ephemeralPrivate,413 ephemeralPublic,414 async (bytes) => socket.send(bytes),415 () => socket.receive(),416 );417 noise = session;418 const channel: PairingTransport = {419 async send(plaintext) { socket.send(session.encrypt(plaintext)); },420 async receive() { return session.decrypt(await socket.receive()); },421 };422423 const deviceName = await getDeviceName();424425 const receiverDeps: AdoptionV2ReceiverDeps = {426 deviceName,427 async confirmSas() {428 const sas = derivePairingSAS(session.handshakeHash);429 const id = crypto.randomUUID();430 return waitForReceiverSasConfirmation(id, sas);431 },432 async createVaultFromRootKey(rootKeyHex) {433 434 435 436 437 438 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);439 createdVault = true;440 return { vaultId };441 },442 async applyLicense(license) {443 444 445 await applyTransferredLicense(license);446 },447 async license() {448 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;449 },450 async rotateLocalIdentity() {451 return await rotatePreVaultIdentity(password);452 },453 async hasSourceIdentityCollision(source) {454 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);455 },456 pairState: {457 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),458 relayUrl,459 },460 onError(error) {461 protocolError = error;462 },463 async localIdentity() {464 return await readPreVaultIdentity(password);465 },466 async validateSource() {},467 async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {468 await storeAdoptedDevice(paired);469 },470 };471 const adoptionResult = await requestAdoptionV2(channel, receiverDeps);472473 if (!adoptionResult) {474 if (createdVault) {475 await rollbackAdoptedVault();476 createdVault = false;477 }478 push("ADOPTION_SOURCE_RESULT", {479 status: "error",480 error: protocolError ?? "adoption_rejected",481 });482 return;483 }484 const { device, snapshotCapable } = adoptionResult;485 486 487 488 489 490 let snapshotApplied = false;491 if (snapshotCapable) try {492 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);493 const adapter = createSyncVaultAdapter();494 await adapter.applyEntries(snapshot.entries);495 await adapter.applyTombstones(snapshot.tombstones);496 497 498 if (snapshot.duressSlot) {499 try {500 await installAdoptedDuressSlot(snapshot.duressSlot);501 } catch (duressError) {502 console.info(503 "[Peach] adopted duress slot install failed (non-fatal):",504 duressError,505 );506 }507 }508 await sendFrame(channel, {509 type: "ADOPT_SNAPSHOT_APPLIED",510 entries_received: Object.keys(snapshot.entries).length,511 tombstones_received: Object.keys(snapshot.tombstones).length,512 });513514 515 516 try {517 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);518 if (519 isAdoptDeviceManifestFrame(manifestFrame) &&520 adapter.applyIncomingDeviceManifest &&521 typeof device.longTermPublicKeyHex === "string" &&522 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)523 ) {524 await adapter.applyIncomingDeviceManifest(525 manifestFrame.manifest,526 hexToBytes(device.longTermPublicKeyHex),527 );528 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });529 }530 } catch {531 532 }533534 await markLastSyncedAt(device.deviceId, Date.now());535 snapshotApplied = true;536 } catch (snapshotError) {537 console.info(538 "[Peach] inline snapshot failed, falling back to sync pipeline:",539 snapshotError,540 );541 }542543 544 545 546 547 548 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(549 () => undefined,550 );551 push("ADOPTION_SOURCE_RESULT", { status: "complete" });552 createdVault = false;553554 if (snapshotApplied) {555 556 557 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);558 } else {559 560 561 void triggerSyncIfReachable({ reason: "manual", force: true });562 schedulePostAdoptionSync();563 }564 } catch (error) {565 if (createdVault) {566 try {567 await rollbackAdoptedVault();568 createdVault = false;569 } catch (rollbackError) {570 console.error("[Peach] rollback failed:", rollbackError);571 }572 }573 console.error("[Peach] receiver session failed:", error);574 push("ADOPTION_SOURCE_RESULT", {575 status: "error",576 error: error instanceof Error ? error.message : "adoption_failed",577 });578 } finally {579 clearTimeout(timeout);580 noise?.close();581 socket.close();582 ephemeralPrivate.fill(0);583 ephemeralPublic.fill(0);584 }585}586587588589590591592async function applyTransferredLicense(license: string): Promise<void> {593 const activated = await activateLicense(license);594 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {595 throw new Error("license_transfer_failed");596 }597}598599600601602603604605606607608609function waitForReceiverSasConfirmation(610 id: string,611 sas: string,612): Promise<boolean> {613 return new Promise<boolean>((resolve) => {614 const timer = setTimeout(() => {615 pendingReceiverSasConfirmation.delete(id);616 resolve(false);617 }, SOURCE_WINDOW_MS);618 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {619 clearTimeout(timer);620 resolve(ok);621 } });622 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });623 });624}625626function waitForSourceSasConfirmation(627 id: string,628 request: { device_name: string },629 sas: string,630): Promise<boolean> {631 return new Promise<boolean>((resolve) => {632 const timer = setTimeout(() => {633 pendingSourceSasConfirmation.delete(id);634 resolve(false);635 }, SOURCE_WINDOW_MS);636 pendingSourceSasConfirmation.set(id, {637 resolve: (ok) => {638 clearTimeout(timer);639 resolve(ok);640 },641 });642 push("ADOPTION_SOURCE_SAS", {643 id,644 sas,645 receiverDeviceName: request.device_name,646 });647 });648}649650651652653654655656function requestConsent(657 request: { device_name: string },658 sas: string,659 id: string,660): Promise<boolean> {661 return waitForSourceSasConfirmation(id, request, sas);662}663664function clearState(): void {665 activeSocket = null;666 inFlight = false;667 pendingSourceSasConfirmation.clear();668 pendingReceiverSasConfirmation.clear();669}670671function constantTimeEqual(a: string, b: string): boolean {672 if (a.length !== b.length) return false;673 let diff = 0;674 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);675 return diff === 0;676}677678function encodeJson(value: unknown): Uint8Array {679 return new TextEncoder().encode(JSON.stringify(value));680}681682683684685686687688689690691export function buildSourceSessionDeps(input: {692 identity: LocalIdentity;693 rendezvousSecretHex: string;694 relayUrl: string | undefined;695 sas: string;696}): AdoptionSourceDeps & AdoptionV2SourceDeps {697 const { identity, rendezvousSecretHex, relayUrl, sas } = input;698 return {699 sourceIdentity: identity,700 pairState: { rendezvousSecretHex, relayUrl },701 async rootKeyHex() {702 703 704 const value = getRootKeyHex();705 if (!value) throw new Error("vault_locked");706 return value;707 },708 async vaultId() {709 const value = getRootKeyHex();710 if (!value) throw new Error("vault_locked");711 const bytes = hexToBytes(value);712 try {713 return await computeVaultId(bytes);714 } finally {715 bytes.fill(0);716 }717 },718 async license() {719 return (await isProUser()) ? ((await getLicenseKey()) ?? undefined) : undefined;720 },721 async applyLicense(license) {722 await applyTransferredLicense(license);723 },724 onConsent: (adoptRequest) => requestConsent(adoptRequest, sas, crypto.randomUUID()),725 persistPendingHolder: persistPendingAdoptionHolder,726 clearPendingHolder: async (adoptRequest) => {727 const current = await readPendingAdoptionHolder();728 if (current?.request.device_id === adoptRequest.device_id) {729 await removePendingAdoptionHolder();730 }731 },732 async upsertPairedDevice(paired) {733 await storeAdoptedDevice(paired);734 },735 hasDeviceIdentityCollision: async (device) =>736 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),737 rotateSourceIdentity: async () => {738 await rotateDeviceId();739 return await buildLocalIdentity();740 },741 persistPendingIdentity: persistPendingAdoptionIdentity,742 async completePendingIdentity(paired: PairedDevice) {743 await completePendingAdoptionIdentity(paired);744 },745 snapshotCapable: () => true,746 };747}748749function decodeJson<T>(value: Uint8Array): T {750 return JSON.parse(new TextDecoder().decode(value)) as T;751}752753754755756757758759async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {760 const secret = paired.rendezvousSecretHex;761 const pub = { ...paired };762 delete pub.rendezvousSecretHex;763 764 765 await upsertPairedDeviceAndClearRevocation(pub);766 767 768 769 770 await onDirectV2PairingEstablished(browserGenerationIo, true);771 if (!isValidPairSecret(secret)) return;772 const masterKeyHex = getRootKeyHex();773 if (!masterKeyHex) return;774 const masterKey = hexToBytes(masterKeyHex);775 try {776 await setPairSecret(paired.deviceId, secret, masterKey);777 } finally {778 masterKey.fill(0);779 }780}781782function hexToBytes(hex: string): Uint8Array {783 const out = new Uint8Array(hex.length / 2);784 for (let i = 0; i < out.length; i++) {785 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);786 }787 return out;788}