keeweb/desktop/scripts/ipc-handlers/browser-extension-connector.js

411 lines
12 KiB
JavaScript

const os = require('os');
const fs = require('fs');
const path = require('path');
const net = require('net');
const { ipcMain, app } = require('electron');
const { Logger } = require('../logger');
const { getProcessInfo } = require('../util/process-utils');
const { ExtensionIds } = require('../const/extension-ids');
const browserExtensionInstaller = require('../util/browser-extension-installer');
ipcMain.handle('browserExtensionConnectorStart', browserExtensionConnectorStart);
ipcMain.handle('browserExtensionConnectorStop', browserExtensionConnectorStop);
ipcMain.handle('browserExtensionConnectorEnable', browserExtensionConnectorEnable);
ipcMain.handle('browserExtensionConnectorSocketResult', browserExtensionConnectorSocketResult);
ipcMain.handle('browserExtensionConnectorSocketEvent', browserExtensionConnectorSocketEvent);
ipcMain.handle('browserExtensionConnectorCloseSocket', browserExtensionConnectorCloseSocket);
const logger = new Logger('browser-extension-connector');
const BrowserExtensionNames = {
KWC: 'KeeWeb Connect',
KPXC: 'KeePassXC-Browser'
};
const MaxIncomingDataLength = 10_000;
const ExtensionOrigins = {
[ExtensionIds.Origins.KeeWebConnectSafari]: BrowserExtensionNames.KWC,
[ExtensionIds.Origins.KeeWebConnectFirefox]: BrowserExtensionNames.KWC,
[ExtensionIds.Origins.KeeWebConnectChrome]: BrowserExtensionNames.KWC,
[ExtensionIds.Origins.KeeWebConnectEdge]: BrowserExtensionNames.KWC,
[ExtensionIds.Origins.KeePassXcBrowserFirefox]: BrowserExtensionNames.KPXC,
[ExtensionIds.Origins.KeePassXcBrowserChrome]: BrowserExtensionNames.KPXC,
[ExtensionIds.Origins.KeePassXcBrowserEdge]: BrowserExtensionNames.KPXC
};
for (const devExtId of process.env.KEEWEB_BROWSER_EXTENSION_IDS_CHROMIUM?.split(',') || []) {
ExtensionOrigins[`chrome-extension://${devExtId}/`] = BrowserExtensionNames.KWC;
}
const AppNames = {
'msedge': 'Microsoft Edge',
'chrome': 'Google Chrome'
};
let connectedSockets = new Map();
let connectedSocketState = new WeakMap();
let server;
let serverSocketName;
let socketId = 0;
async function browserExtensionConnectorStart(e, config) {
serverSocketName = getBrowserExtensionSocketName(config);
await prepareBrowserExtensionSocket();
if (isSocketNameTooLong(serverSocketName)) {
logger.error(
"Socket name is too long, browser connection won't be possible, probably OS username is very long.",
serverSocketName
);
return;
}
server = net.createServer(async (socket) => {
socketId++;
logger.info(`New connection with socket ${socketId}`);
connectedSockets.set(socketId, socket);
connectedSocketState.set(socket, { socketId });
socket.on('data', (data) => onSocketData(socket, data));
socket.on('close', () => onSocketClose(socket));
});
server.listen(serverSocketName);
logger.info('Started');
}
function browserExtensionConnectorStop() {
for (const socket of connectedSockets.values()) {
socket.destroy();
}
if (server) {
server.close();
server = null;
}
connectedSockets = new Map();
connectedSocketState = new WeakMap();
logger.info('Stopped');
}
async function browserExtensionConnectorEnable(e, browser, extension, enabled) {
logger.info(enabled ? 'Enable' : 'Disable', browser, extension);
try {
if (enabled) {
await browserExtensionInstaller.install(browser, extension);
} else {
await browserExtensionInstaller.uninstall(browser, extension);
}
} catch (e) {
logger.error(`Error installing extension: ${e}`);
}
}
function browserExtensionConnectorSocketResult(e, socketId, result) {
sendResultToSocket(socketId, result);
}
function browserExtensionConnectorSocketEvent(e, data) {
sendEventToAllSockets(data);
}
function browserExtensionConnectorCloseSocket(e, socketId) {
const socket = connectedSockets.get(socketId);
socket?.destroy();
}
function getBrowserExtensionSocketName(config) {
const { username, uid } = os.userInfo();
if (process.platform === 'darwin') {
const appleTeamId = config.appleTeamId;
return `/Users/${username}/Library/Group Containers/${appleTeamId}.keeweb/conn.sock`;
} else if (process.platform === 'win32') {
return `\\\\.\\pipe\\keeweb-connect-${username}`;
} else {
const sockFileName = `keeweb-connect-${uid}.sock`;
return path.join(app.getPath('temp'), sockFileName);
}
}
function prepareBrowserExtensionSocket() {
return new Promise((resolve) => {
if (process.platform === 'darwin') {
fs.access(serverSocketName, fs.constants.F_OK, (err) => {
if (err) {
const dir = path.dirname(serverSocketName);
fs.mkdir(dir, () => resolve());
} else {
fs.unlink(serverSocketName, () => resolve());
}
});
} else if (process.platform === 'win32') {
return resolve();
} else {
fs.unlink(serverSocketName, () => resolve());
}
});
}
function isSocketNameTooLong(socketName) {
const maxLength = process.platform === 'win32' ? 256 : 104;
return socketName.length > maxLength;
}
function onSocketClose(socket) {
const state = connectedSocketState.get(socket);
connectedSocketState.delete(socket);
if (state?.socketId) {
connectedSockets.delete(state.socketId);
sendToRenderer('browserExtensionSocketClosed', state.socketId);
}
logger.info(`Socket ${state?.socketId} closed`);
}
function onSocketData(socket, data) {
const state = connectedSocketState.get(socket);
if (!state) {
logger.warn('Received data without connection state');
return;
}
if (data.byteLength > MaxIncomingDataLength) {
logger.warn(`Too many bytes rejected from socket ${state.socketId}`, data.byteLength);
socket.destroy();
return;
}
if (state.pendingData) {
state.pendingData = Buffer.concat([state.pendingData, data]);
} else {
state.pendingData = data;
}
processPendingSocketData(socket);
}
async function processPendingSocketData(socket) {
const state = connectedSocketState.get(socket);
if (!state) {
return;
}
if (!state.pendingData || state.processingData) {
return;
}
if (state.pendingData.length < 4) {
return;
}
const lengthBuffer = state.pendingData.buffer.slice(
state.pendingData.byteOffset,
state.pendingData.byteOffset + 4
);
const length = new Uint32Array(lengthBuffer)[0];
if (length > MaxIncomingDataLength) {
logger.warn(`Large message rejected from socket ${state.socketId}`, length);
socket.destroy();
return;
}
if (state.pendingData.byteLength < length + 4) {
return;
}
const messageBytes = state.pendingData.slice(4, length + 4);
if (state.pendingData.byteLength > length + 4) {
state.pendingData = state.pendingData.slice(length + 4);
} else {
state.pendingData = null;
}
const str = messageBytes.toString();
let request;
try {
request = JSON.parse(str);
} catch {
logger.warn(`Failed to parse message from socket ${state.socketId}`, str);
socket.destroy();
return;
}
if (!state.active) {
await processFirstMessageFromSocket(socket, request);
return;
}
logger.debug(`Extension[${state.socketId}] -> KeeWeb`, request);
if (!request) {
logger.warn(`Empty request for socket ${state.socketId}`, request);
socket.destroy();
return;
}
if (request.clientID) {
const clientId = request.clientID;
if (!state.clientId) {
state.clientId = clientId;
} else if (state.clientId !== clientId) {
logger.warn(
`Changing client ID for socket ${state.socketId} is not allowed`,
`${state.clientId} => ${clientId}`
);
socket.destroy();
return;
}
} else {
if (request.action !== 'ping') {
logger.warn(`Empty client ID in socket request ${state.socketId}`, request);
socket.destroy();
return;
}
}
state.processingData = true;
sendToRenderer('browserExtensionSocketRequest', state.socketId, request);
}
async function processFirstMessageFromSocket(socket, message) {
const state = connectedSocketState.get(socket);
if (!state) {
return;
}
logger.debug(`Init connection ${state.socketId}`, message);
state.processingData = true;
if (!message.origin) {
logger.error('Empty origin');
socket.destroy();
return;
}
if (!message.pid) {
logger.error('Empty pid');
socket.destroy();
return;
}
const extensionName = ExtensionOrigins[message.origin] || 'unknown';
const isSafari = message.origin === ExtensionIds.Origins.KeeWebConnectSafari;
let appName;
if (isSafari) {
appName = 'Safari';
} else {
if (!message.ppid) {
logger.error('Empty ppid');
socket.destroy();
return;
}
let parentProcessInfo;
try {
try {
parentProcessInfo = await getProcessInfo(message.ppid);
} catch (e) {
logger.error(`Cannot get info for PID ${message.ppid}: ${e}`);
throw e;
}
if (process.platform === 'win32' && parentProcessInfo.appName === 'cmd') {
try {
parentProcessInfo = await getProcessInfo(parentProcessInfo.ppid);
} catch (e) {
logger.error(
`Cannot get info for PID ${parentProcessInfo.ppid}: ${e}, assuming cmd is the launcher`
);
}
}
} catch (e) {
logger.warn('Cannot get process info, assuming the connection is not identified');
}
appName = parentProcessInfo
? AppNames[parentProcessInfo.appName] ?? parentProcessInfo.appName
: 'Unidentified browser';
appName = appName[0].toUpperCase() + appName.substr(1);
}
state.active = true;
state.appName = appName;
state.extensionName = extensionName;
state.pid = message.pid;
state.ppid = message.ppid;
state.supportsNotifications = !isSafari;
state.processingData = false;
logger.info(
`Socket ${state.socketId} activated for ` +
`app: "${state.appName}", ` +
`extension: "${state.extensionName}", ` +
`pid: ${state.pid}, ` +
`ppid: ${state.ppid}`
);
sendToRenderer('browserExtensionSocketConnected', state.socketId, {
connectionId: state.socketId,
appName: state.appName,
extensionName: state.extensionName,
pid: state.pid,
supportsNotifications: state.supportsNotifications
});
processPendingSocketData(socket);
}
function sendResultToSocket(socketId, result) {
const socket = connectedSockets.get(socketId);
if (socket) {
sendMessageToSocket(socket, result);
const state = connectedSocketState.get(socket);
if (state.processingData) {
state.processingData = false;
processPendingSocketData(socket);
}
}
}
function sendEventToAllSockets(data) {
for (const socket of connectedSockets.values()) {
const state = connectedSocketState.get(socket);
if (state?.active && state?.supportsNotifications) {
sendMessageToSocket(socket, data);
}
}
}
function sendMessageToSocket(socket, message) {
const state = connectedSocketState.get(socket);
if (!state) {
logger.warn('Ignoring a socket message without connection state');
return;
}
if (!state.active) {
logger.warn(`Ignoring a message to inactive socket ${state.socketId}`);
return;
}
logger.debug(`KeeWeb -> Extension[${state.socketId}]`, message);
const responseData = Buffer.from(JSON.stringify(message));
const lengthBuf = Buffer.from(new Uint32Array([responseData.byteLength]).buffer);
const lengthBytes = Buffer.from(lengthBuf);
const data = Buffer.concat([lengthBytes, responseData]);
try {
socket.write(data);
} catch (e) {
logger.error(`Error writing to socket ${state.socketId}`, e);
}
}
function sendToRenderer(event, socketId, data) {
app.getMainWindow().webContents.send(event, socketId, data);
}