diff --git a/app/scripts/app.js b/app/scripts/app.js index 039d327d..220ae436 100644 --- a/app/scripts/app.js +++ b/app/scripts/app.js @@ -178,7 +178,7 @@ ready(() => { AppRightsChecker.init(); IdleTracker.init(); UsbListener.init(); - BrowserExtensionConnector.init(); + BrowserExtensionConnector.init(appModel); setTimeout(() => { PluginManager.runAutoUpdate(); }, Timeouts.AutoUpdatePluginsAfterStart); diff --git a/app/scripts/comp/app/browser-extension-connector.js b/app/scripts/comp/app/browser-extension-connector.js index 092a5789..c84e9051 100644 --- a/app/scripts/comp/app/browser-extension-connector.js +++ b/app/scripts/comp/app/browser-extension-connector.js @@ -4,12 +4,13 @@ import { Events } from 'framework/events'; import { RuntimeInfo } from 'const/runtime-info'; import { Launcher } from 'comp/launcher'; import { AppSettingsModel } from 'models/app-settings-model'; -import { AppModel } from 'models/app-model'; import { Alerts } from 'comp/ui/alerts'; import { PasswordGenerator } from 'util/generators/password-generator'; import { GeneratorPresets } from 'comp/app/generator-presets'; +let appModel; const connectedClients = {}; +const MaxIncomingDataLength = 10000; function incrementNonce(nonce) { // from libsodium/utils.c, like it is in KeePassXC @@ -107,13 +108,13 @@ const ProtocolHandlers = { 'get-databasehash'(request) { decryptRequest(request); - const firstFile = AppModel.instance.files.firstActiveKdbxFile(); + const firstFile = appModel.files.firstActiveKdbxFile(); if (firstFile?.defaultGroupHash) { return encryptResponse(request, { action: 'hash', version: RuntimeInfo.version, hash: firstFile.defaultGroupHash, - hashes: AppModel.instance.files + hashes: appModel.files .filter((file) => file.active && !file.backend) .map((file) => file.defaultGroupHash) }); @@ -158,7 +159,9 @@ const ProtocolHandlers = { const BrowserExtensionConnector = { enabled: false, - init() { + init(model) { + appModel = model; + this.browserWindowMessage = this.browserWindowMessage.bind(this); this.fileOpened = this.fileOpened.bind(this); this.oneFileClosed = this.oneFileClosed.bind(this); @@ -179,7 +182,9 @@ const BrowserExtensionConnector = { }, start() { - if (!Launcher) { + if (Launcher) { + this.startDesktopAppListener(); + } else { this.startWebMessageListener(); } Events.on('file-opened', this.fileOpened); @@ -188,7 +193,9 @@ const BrowserExtensionConnector = { }, stop() { - if (!Launcher) { + if (Launcher) { + this.stopDesktopAppListener(); + } else { this.stopWebMessageListener(); } Events.off('file-opened', this.fileOpened); @@ -204,6 +211,103 @@ const BrowserExtensionConnector = { window.removeEventListener('message', this.browserWindowMessage); }, + startDesktopAppListener() { + Launcher.closeOldBrowserExtensionSocket(() => { + const sockName = Launcher.getBrowserExtensionSocketName(); + const { createServer } = Launcher.req('net'); + this.connectedSockets = []; + this.connectedSocketState = new WeakMap(); + this.server = createServer((socket) => { + // TODO: identity check + this.connectedSockets.push(socket); + this.connectedSocketState.set(socket, { active: true }); + socket.on('data', (data) => { + this.onSocketData(socket, data); + }); + socket.on('close', () => { + // TODO: remove the client + this.connectedSockets = this.connectedSockets.filter((s) => s !== socket); + this.connectedSocketState.delete(socket); + }); + }); + this.server.listen(sockName); + }); + }, + + stopDesktopAppListener() { + for (const socket of this.connectedSockets) { + socket.destroy(); + } + if (this.server) { + this.server.close(); + } + this.connectedSockets = []; + this.connectedSocketState = new WeakMap(); + }, + + onSocketData(socket, data) { + if (data.byteLength > MaxIncomingDataLength) { + socket.destroy(); + return; + } + const state = this.connectedSocketState.get(socket); + if (!state) { + return; + } + if (state.pendingData) { + state.pendingData = Buffer.concat([state.pendingData, data]); + } else { + state.pendingData = data; + } + if (state.pendingData.length < 4) { + return; + } + + while (state.pendingData) { + const lengthBuffer = state.pendingData.slice(0, 4); + const length = new Uint32Array(lengthBuffer)[0]; + + if (length > MaxIncomingDataLength) { + 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 { + socket.destroy(); + return; + } + + let response; + try { + const handler = ProtocolHandlers[request.action]; + if (!handler) { + throw new Error(`Handler not found: ${request.action}`); + } + response = handler(request) || {}; + } catch (e) { + response = { error: e.message || 'Unknown error' }; + } + if (response) { + this.sendSocketResponse(socket, response); + } + } + }, + browserWindowMessage(e) { if (e.origin !== location.origin) { return; @@ -225,32 +329,60 @@ const BrowserExtensionConnector = { response = { error: e.message || 'Unknown error' }; } if (response) { - this.sendResponse(response); + this.sendWebResponse(response); } }, - sendResponse(response) { + sendWebResponse(response) { response.kwConnect = 'response'; postMessage(response, window.location.origin); }, + sendSocketResponse(socket, response) { + const responseData = Buffer.from(JSON.stringify(response)); + const lengthBytes = Buffer.from(new Uint32Array([responseData.byteLength]).buffer); + const data = Buffer.concat([lengthBytes, responseData]); + socket.write(data); + }, + + sendSocketEvent(data) { + for (const socket of this.connectedSockets) { + const state = this.connectedSocketState.get(socket); + if (state?.active) { + this.sendSocketResponse(socket, data); + } + } + }, + + sendEvent(data) { + if (Launcher) { + this.sendSocketEvent(data); + } else { + this.sendWebResponse(data); + } + }, + fileOpened() { - this.sendResponse({ action: 'database-unlocked' }); + this.sendEvent({ action: 'database-unlocked' }); }, oneFileClosed() { - this.sendResponse({ action: 'database-locked' }); - if (AppModel.instance.files.hasOpenFiles()) { - this.sendResponse({ action: 'database-unlocked' }); + this.sendEvent({ action: 'database-locked' }); + if (appModel.files.hasOpenFiles()) { + this.sendEvent({ action: 'database-unlocked' }); } }, allFilesClosed() { - this.sendResponse({ action: 'database-locked' }); + this.sendEvent({ action: 'database-locked' }); }, focusKeeWeb() { - this.sendResponse({ action: 'attention-required' }); + if (Launcher) { + Launcher.showMainWindow(); + } else { + this.sendEvent({ action: 'attention-required' }); + } } }; diff --git a/app/scripts/comp/launcher/launcher-electron.js b/app/scripts/comp/launcher/launcher-electron.js index 85407029..5df07834 100644 --- a/app/scripts/comp/launcher/launcher-electron.js +++ b/app/scripts/comp/launcher/launcher-electron.js @@ -288,6 +288,18 @@ const Launcher = { }, mainWindowMaximized() { return this.getMainWindow().isMaximized(); + }, + getBrowserExtensionSocketName() { + if (process.platform === 'win32') { + return '\\\\.\\pipe\\keeweb-browser'; + } else { + return this.joinPath(this.remoteApp().getPath('temp'), 'keeweb-browser.sock'); + } + }, + closeOldBrowserExtensionSocket(done) { + if (process.platform !== 'win32') { + this.deleteFile(this.getBrowserExtensionSocketName(), done); + } } }; diff --git a/extension/native-messaging-host/src/main.cpp b/extension/native-messaging-host/src/main.cpp index 47f31ea8..e9841d04 100644 --- a/extension/native-messaging-host/src/main.cpp +++ b/extension/native-messaging-host/src/main.cpp @@ -11,7 +11,7 @@ // https://developer.chrome.com/docs/apps/nativeMessaging/#native-messaging-host-protocol // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging#app_side -#if defined(WIN32) || defined(_WIN32) || defined(__WIN32) && !defined(__CYGWIN__) +#if defined(WIN32) || defined(_WIN32) || defined(__WIN32) #define APP_EXECUTABLE_FILE_NAME "KeeWeb.exe" #elif __APPLE__ #define APP_EXECUTABLE_FILE_NAME "KeeWeb" @@ -21,10 +21,10 @@ constexpr auto kKeeWebLaunchArg = "--browser-extension"; -constexpr auto kSockName = "keeweb.sock"; +constexpr auto kSockName = "keeweb-browser.sock"; constexpr std::array kAllowedOrigins = { - std::string_view("chrome-extension://enjifmdnhaddmajefhfaoglcfdobkcpj")}; + std::string_view("chrome-extension://enjifmdnhaddmajefhfaoglcfdobkcpj/")}; constexpr uint32_t kMaxKeeWebConnectAttempts = 10; constexpr uint32_t kMaxKeeWebConnectRetryTimeoutMillis = 500; diff --git a/extension/native-messaging-host/test/native-messaging-host-test.mjs b/extension/native-messaging-host/test/native-messaging-host-test.mjs index 16feae0d..dc23ad27 100644 --- a/extension/native-messaging-host/test/native-messaging-host-test.mjs +++ b/extension/native-messaging-host/test/native-messaging-host-test.mjs @@ -6,7 +6,7 @@ import childProcess from 'child_process'; import { expect } from 'chai'; describe('KeeWeb extension native module host', function () { - const sockPath = path.join(os.tmpdir(), 'keeweb.sock'); + const sockPath = path.join(os.tmpdir(), 'keeweb-browser.sock'); const hostPath = 'build/keeweb-native-messaging-host'; const extensionOrigin = 'chrome-extension://enjifmdnhaddmajefhfaoglcfdobkcpj';