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 {32 bytesToHex,33 deriveRendezvousSecret,34 generatePairingRendezvousToken,35} from "../shared/rendezvous";36import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37import { getSyncSettings, relayInForce } from "./sync-settings";38import { activateLicense, getLicenseKey, isProUser } from "./license";39import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";40import { computeVaultId } from "../shared/sync-identity";41import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";42import { storageRemove, storageSet } from "../shared/storage";43import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44import { createSyncVaultAdapter } from "./sync-vault-adapter";45import { triggerSyncIfReachable } from "./sync-trigger";46import { schedulePostAdoptionSync } from "./sync-alarm";4748const SOURCE_WINDOW_MS = 5 * 60 * 1000;4950function push(type: string, payload: unknown): void {51 try {52 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;53 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {54 (maybe as Promise<unknown>).catch(() => undefined);55 }56 } catch {57 58 }59}606162function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {63 return new Promise<T>((resolve, reject) => {64 const timer = setTimeout(() => reject(new Error("timeout")), ms);65 promise.then(66 (value) => {67 clearTimeout(timer);68 resolve(value);69 },70 (error) => {71 clearTimeout(timer);72 reject(error);73 },74 );75 });76}7778let inFlight = false;79let activeSocket: PairingSocket | null = null;8081828384const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8586export function isRelayAdoptionSourceInFlight(): boolean {87 return inFlight;88}899091929394export async function startRelayAdoptionSource(): Promise<{95 invitation: string;96}> {97 if (inFlight) throw new Error("adoption_source_in_progress");98 if (!isUnlocked()) throw new Error("vault_locked");99 inFlight = true;100101 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;102 try {103 const settings = await getSyncSettings();104 const relayUrl = relayInForce(settings);105 local = await getLocalPairingIdentity();106 const token = generatePairingRendezvousToken();107 108 109 const encoded = createPairingInvitation(relayUrl, token, "populated");110 const invitation = parsePairingInvitation(encoded);111 if (!invitation) throw new Error("invitation_generation_failed");112113 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));114 activeSocket = socket;115 const readyLocal = local;116 void runSourceSession(117 socket,118 token,119 relayUrl,120 readyLocal,121 ).finally(() => {122 readyLocal.privateKey.fill(0);123 readyLocal.publicKey.fill(0);124 clearState();125 });126 return { invitation: encoded };127 } catch (error) {128 local?.privateKey.fill(0);129 local?.publicKey.fill(0);130 clearState();131 throw error;132 }133}134135export function cancelRelayAdoptionSource(): void {136 activeSocket?.close();137 clearState();138}139140141142143144145146147148149150151export async function startRelayAdoptionReceiver(152 password: string,153): Promise<{ invitation: string }> {154 if (inFlight) throw new Error("adoption_receiver_in_progress");155 if (typeof password !== "string" || password.length === 0) {156 throw new Error("ADOPT_INVALID_PASSWORD");157 }158 inFlight = true;159 try {160 const settings = await getSyncSettings();161 const relayUrl = relayInForce(settings);162 const token = generatePairingRendezvousToken();163 const encoded = createPairingInvitation(relayUrl, token, "empty");164 const invitation = parsePairingInvitation(encoded);165 if (!invitation) throw new Error("invitation_generation_failed");166 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));167 activeSocket = socket;168 void runReceiverSession(169 socket,170 token,171 relayUrl,172 password,173 ).finally(clearState);174 return { invitation: encoded };175 } catch (error) {176 clearState();177 throw error;178 }179}180181182183184185186187188export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189 const pending = pendingSourceSasConfirmation.get(id);190 if (!pending) return false;191 pendingSourceSasConfirmation.delete(id);192 pending.resolve(ok);193 return true;194}195196async function runSourceSession(197 socket: PairingSocket,198 expectedToken: string,199 relayUrl: string,200 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201): Promise<void> {202 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203 let noise: NoiseNKSession | undefined;204 try {205 const hello = decodeJson<{ type?: unknown; token?: unknown }>(206 await socket.receive(),207 );208 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209 !constantTimeEqual(hello.token, expectedToken)) {210 throw new Error("invalid_invitation");211 }212 socket.send(encodeJson({213 type: "PEACH_HELLO_ACK",214 ephemeralPublicKeyHex: bytesToHex(local.publicKey),215 }));216217 const session = await noiseNKResponder(218 local.privateKey,219 local.publicKey,220 async (bytes) => socket.send(bytes),221 () => socket.receive(),222 );223 noise = session;224 const channel: PairingTransport = {225 async send(plaintext) { socket.send(session.encrypt(plaintext)); },226 async receive() { return session.decrypt(await socket.receive()); },227 };228 const request = await receiveFrame(channel);229 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230231 const device = await grantAdoption(channel, request, {232 sourceIdentity: local.identity,233 pairState: {234 rendezvousSecretHex: bytesToHex(235 deriveRendezvousSecret(session.handshakeHash),236 ),237 relayUrl,238 },239 async rootKeyHex() {240 241 242 const value = getRootKeyHex();243 if (!value) throw new Error("vault_locked");244 return value;245 },246 async vaultId() {247 const value = getRootKeyHex();248 if (!value) throw new Error("vault_locked");249 const bytes = hexToBytes(value);250 try {251 return await computeVaultId(bytes);252 } finally {253 bytes.fill(0);254 }255 },256 async license() {257 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258 },259 async applyLicense(license) {260 await applyTransferredLicense(license);261 },262 onConsent: (adoptRequest) => requestConsent(263 adoptRequest,264 derivePairingSAS(session.handshakeHash),265 crypto.randomUUID(),266 ),267 upsertPairedDevice,268 });269 if (!device) {270 push("ADOPTION_SOURCE_RESULT", {271 status: "error",272 error: "adoption_rejected",273 });274 return;275 }276 277 278 279 if (request.snapshot_capable === true) {280 try {281 const adapter = createSyncVaultAdapter();282 const entryManifest = await adapter.buildEntryManifest();283 const entryIds = Object.keys(entryManifest);284 const entries =285 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286 const tombstones = await adapter.buildTombstoneManifest();287 let duressSlot: string | null = null;288 try {289 duressSlot = await exportDuressSlotForAdoption();290 } catch {291 292 }293 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);294295 const applied = await withTimeout(receiveFrame(channel), 15_000);296 if (isAdoptSnapshotApplied(applied)) {297 console.info(298 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,299 );300 }301302 if (adapter.buildOutgoingDeviceManifest) {303 const manifest = await adapter.buildOutgoingDeviceManifest();304 if (manifest) {305 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });306 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);307 }308 }309 } catch (snapshotError) {310 console.info(311 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",312 snapshotError,313 );314 }315 }316 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });317 318 319 320 void triggerSyncIfReachable({ reason: "manual", force: true });321 schedulePostAdoptionSync();322 } catch (error) {323 push("ADOPTION_SOURCE_RESULT", {324 status: "error",325 error: error instanceof Error ? error.message : "adoption_failed",326 });327 } finally {328 clearTimeout(timeout);329 noise?.close();330 socket.close();331 pendingSourceSasConfirmation.clear();332 }333}334335336337338339340async function runReceiverSession(341 socket: PairingSocket,342 expectedToken: string,343 relayUrl: string,344 password: string,345): Promise<void> {346 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);347 348 const ephemeral = generateKeyPair();349 const ephemeralPrivate = ephemeral.secretKey;350 const ephemeralPublic = ephemeral.publicKey;351 let noise: NoiseNKSession | undefined;352 let createdVault = false;353 let protocolError: string | undefined;354 try {355 const hello = decodeJson<{ type?: unknown; token?: unknown }>(356 await socket.receive(),357 );358 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||359 !constantTimeEqual(hello.token, expectedToken)) {360 throw new Error("invalid_invitation");361 }362 socket.send(encodeJson({363 type: "PEACH_HELLO_ACK",364 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),365 }));366367 const session = await noiseNKResponder(368 ephemeralPrivate,369 ephemeralPublic,370 async (bytes) => socket.send(bytes),371 () => socket.receive(),372 );373 noise = session;374 const channel: PairingTransport = {375 async send(plaintext) { socket.send(session.encrypt(plaintext)); },376 async receive() { return session.decrypt(await socket.receive()); },377 };378379 const deviceName = await getDeviceName();380381 const device = await requestAdoption(channel, {382 deviceName,383 generateDeviceId: () => crypto.randomUUID(),384 385 386 387 388 389 390 async confirmSas() {391 push("ADOPTION_SOURCE_SAS", {392 sas: derivePairingSAS(session.handshakeHash),393 displayOnly: true,394 });395 return true;396 },397 async createVaultFromRootKey(rootKeyHex) {398 399 400 401 402 403 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);404 createdVault = true;405 return { vaultId };406 },407 async applyLicense(license) {408 409 410 await applyTransferredLicense(license);411 },412 async license() {413 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;414 },415 async deriveLocalIdentity() {416 417 const masterKeyHex = getRootKeyHex();418 if (!masterKeyHex) throw new Error("identity_unavailable");419 const masterKey = hexToBytes(masterKeyHex);420 try {421 return await buildLocalIdentity(masterKey);422 } finally {423 masterKey.fill(0);424 }425 },426 async upsertPairedDevice(paired) {427 428 await upsertPairedDevice(paired);429 },430 pairState: {431 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),432 relayUrl,433 },434 onError(error) {435 protocolError = error;436 },437 });438439 if (!device) {440 if (createdVault) {441 await rollbackAdoptedVault();442 createdVault = false;443 }444 push("ADOPTION_SOURCE_RESULT", {445 status: "error",446 error: protocolError ?? "adoption_rejected",447 });448 return;449 }450 451 452 453 454 let snapshotApplied = false;455 try {456 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);457 const adapter = createSyncVaultAdapter();458 await adapter.applyEntries(snapshot.entries);459 await adapter.applyTombstones(snapshot.tombstones);460 461 462 if (snapshot.duressSlot) {463 try {464 await installAdoptedDuressSlot(snapshot.duressSlot);465 } catch (duressError) {466 console.info(467 "[Peach] adopted duress slot install failed (non-fatal):",468 duressError,469 );470 }471 }472 await sendFrame(channel, {473 type: "ADOPT_SNAPSHOT_APPLIED",474 entries_received: Object.keys(snapshot.entries).length,475 tombstones_received: Object.keys(snapshot.tombstones).length,476 });477478 479 480 try {481 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);482 if (483 isAdoptDeviceManifestFrame(manifestFrame) &&484 adapter.applyIncomingDeviceManifest &&485 typeof device.longTermPublicKeyHex === "string" &&486 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)487 ) {488 await adapter.applyIncomingDeviceManifest(489 manifestFrame.manifest,490 hexToBytes(device.longTermPublicKeyHex),491 );492 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });493 }494 } catch {495 496 }497498 await markLastSyncedAt(device.deviceId, Date.now());499 snapshotApplied = true;500 } catch (snapshotError) {501 console.info(502 "[Peach] inline snapshot failed, falling back to sync pipeline:",503 snapshotError,504 );505 }506507 508 509 510 511 512 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(513 () => undefined,514 );515 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });516 createdVault = false;517518 if (snapshotApplied) {519 520 521 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);522 } else {523 524 525 void triggerSyncIfReachable({ reason: "manual", force: true });526 schedulePostAdoptionSync();527 }528 } catch (error) {529 if (createdVault) {530 try {531 await rollbackAdoptedVault();532 createdVault = false;533 } catch (rollbackError) {534 console.error("[Peach] rollback failed:", rollbackError);535 }536 }537 console.error("[Peach] receiver session failed:", error);538 push("ADOPTION_SOURCE_RESULT", {539 status: "error",540 error: error instanceof Error ? error.message : "adoption_failed",541 });542 } finally {543 clearTimeout(timeout);544 noise?.close();545 socket.close();546 ephemeralPrivate.fill(0);547 ephemeralPublic.fill(0);548 }549}550551552553554555556async function applyTransferredLicense(license: string): Promise<void> {557 const activated = await activateLicense(license);558 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {559 throw new Error("license_transfer_failed");560 }561}562563564565566567568569570571572573function waitForSourceSasConfirmation(574 id: string,575 request: AdoptRequest,576 sas: string,577): Promise<boolean> {578 return new Promise<boolean>((resolve) => {579 const timer = setTimeout(() => {580 pendingSourceSasConfirmation.delete(id);581 resolve(false);582 }, SOURCE_WINDOW_MS);583 pendingSourceSasConfirmation.set(id, {584 resolve: (ok) => {585 clearTimeout(timer);586 resolve(ok);587 },588 });589 push("ADOPTION_SOURCE_SAS", {590 id,591 sas,592 receiverDeviceName: request.device_name,593 });594 });595}596597598599600601602603function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {604 return waitForSourceSasConfirmation(id, request, sas);605}606607function clearState(): void {608 activeSocket = null;609 inFlight = false;610 pendingSourceSasConfirmation.clear();611}612613function constantTimeEqual(a: string, b: string): boolean {614 if (a.length !== b.length) return false;615 let diff = 0;616 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);617 return diff === 0;618}619620function encodeJson(value: unknown): Uint8Array {621 return new TextEncoder().encode(JSON.stringify(value));622}623624function decodeJson<T>(value: Uint8Array): T {625 return JSON.parse(new TextDecoder().decode(value)) as T;626}627628function hexToBytes(hex: string): Uint8Array {629 const out = new Uint8Array(hex.length / 2);630 for (let i = 0; i < out.length; i++) {631 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);632 }633 return out;634}