keeweb/app/scripts/comp/app/browser-extension-connector.js

493 lines
14 KiB
JavaScript
Raw Normal View History

2021-04-06 20:10:42 +02:00
import kdbxweb from 'kdbxweb';
import { box as tweetnaclBox } from 'tweetnacl';
2021-04-06 21:27:58 +02:00
import { Events } from 'framework/events';
2021-04-06 20:10:42 +02:00
import { RuntimeInfo } from 'const/runtime-info';
2021-04-10 23:35:42 +02:00
import { KnownAppVersions } from 'const/known-app-versions';
2021-04-06 20:10:42 +02:00
import { Launcher } from 'comp/launcher';
import { AppSettingsModel } from 'models/app-settings-model';
import { Alerts } from 'comp/ui/alerts';
import { PasswordGenerator } from 'util/generators/password-generator';
import { GeneratorPresets } from 'comp/app/generator-presets';
2021-04-11 10:17:02 +02:00
import { Logger } from 'util/logger';
const logger = new Logger('browser-extension-connector');
2021-04-06 20:10:42 +02:00
let appModel;
2021-04-11 12:48:58 +02:00
const connectedClients = new Map();
const MaxIncomingDataLength = 10000;
2021-04-06 20:10:42 +02:00
function incrementNonce(nonce) {
// from libsodium/utils.c, like it is in KeePassXC
let i = 0;
let c = 1;
for (; i < nonce.length; ++i) {
c += nonce[i];
nonce[i] = c;
c >>= 8;
}
}
function getClient(request) {
if (!request.clientID) {
throw new Error('Empty clientID');
}
2021-04-11 12:48:58 +02:00
const client = connectedClients.get(request.clientID);
2021-04-06 20:10:42 +02:00
if (!client) {
throw new Error(`Client not connected: ${request.clientID}`);
}
return client;
}
function decryptRequest(request) {
const client = getClient(request);
if (!request.nonce) {
throw new Error('Empty nonce');
}
if (!request.message) {
throw new Error('Empty message');
}
const nonce = kdbxweb.ByteUtils.base64ToBytes(request.nonce);
const message = kdbxweb.ByteUtils.base64ToBytes(request.message);
const data = tweetnaclBox.open(message, nonce, client.publicKey, client.keys.secretKey);
const json = new TextDecoder().decode(data);
const payload = JSON.parse(json);
2021-04-08 17:36:42 +02:00
if (!payload) {
throw new Error('Empty request payload');
}
if (payload.action !== request.action) {
2021-04-06 20:10:42 +02:00
throw new Error(`Bad action in decrypted payload`);
}
return payload;
}
function encryptResponse(request, payload) {
2021-04-08 17:36:42 +02:00
const nonceBytes = kdbxweb.ByteUtils.base64ToBytes(request.nonce);
incrementNonce(nonceBytes);
const nonce = kdbxweb.ByteUtils.bytesToBase64(nonceBytes);
2021-04-06 20:10:42 +02:00
const client = getClient(request);
2021-04-08 17:36:42 +02:00
payload.nonce = nonce;
2021-04-06 20:10:42 +02:00
const json = JSON.stringify(payload);
const data = new TextEncoder().encode(json);
2021-04-08 17:36:42 +02:00
const encrypted = tweetnaclBox(data, nonceBytes, client.publicKey, client.keys.secretKey);
2021-04-06 20:10:42 +02:00
const message = kdbxweb.ByteUtils.bytesToBase64(encrypted);
return {
action: request.action,
message,
nonce
};
}
2021-04-10 23:35:42 +02:00
function getVersion(request) {
2021-04-10 23:36:21 +02:00
const extensionName = getClient(request).extensionName;
2021-04-10 23:35:42 +02:00
return extensionName ? RuntimeInfo.version : KnownAppVersions.KeePassXC;
}
function isKeeWebConnect(request) {
2021-04-10 23:36:21 +02:00
return getClient(request).extensionName === 'keeweb-connect';
2021-04-10 23:35:42 +02:00
}
2021-04-11 15:04:04 +02:00
const ErrorMessages = {
noOpenFiles: 'No open files'
};
const ErrorCode = {
[ErrorMessages.noOpenFiles]: 1
};
2021-04-11 14:53:56 +02:00
2021-04-06 20:10:42 +02:00
const ProtocolHandlers = {
'ping'({ data }) {
return { data };
},
2021-04-10 23:35:42 +02:00
'change-public-keys'(request) {
2021-04-11 12:48:58 +02:00
let { publicKey, extensionName, version, clientID: clientId } = request;
2021-04-10 23:35:42 +02:00
2021-04-11 12:48:58 +02:00
if (connectedClients.has(clientId)) {
2021-04-11 10:17:02 +02:00
throw new Error('Changing keys is not allowed');
}
2021-04-11 12:48:58 +02:00
if (!Launcher) {
// on web there can be only one connected client
connectedClients.clear();
}
2021-04-06 20:10:42 +02:00
const keys = tweetnaclBox.keyPair();
publicKey = kdbxweb.ByteUtils.base64ToBytes(publicKey);
2021-04-11 12:48:58 +02:00
connectedClients.set(clientId, { publicKey, extensionName, version, keys });
2021-04-06 20:10:42 +02:00
2021-04-11 12:48:58 +02:00
logger.info('New client key created', clientId, extensionName, version);
2021-04-11 10:17:02 +02:00
2021-04-11 14:53:56 +02:00
const nonceBytes = kdbxweb.ByteUtils.base64ToBytes(request.nonce);
incrementNonce(nonceBytes);
const nonce = kdbxweb.ByteUtils.bytesToBase64(nonceBytes);
2021-04-06 20:10:42 +02:00
return {
action: 'change-public-keys',
2021-04-10 23:35:42 +02:00
version: getVersion(request),
2021-04-06 20:10:42 +02:00
publicKey: kdbxweb.ByteUtils.bytesToBase64(keys.publicKey),
2021-04-11 14:53:56 +02:00
nonce,
success: 'true',
...(isKeeWebConnect(request) ? { appName: 'KeeWeb' } : undefined)
2021-04-06 20:10:42 +02:00
};
},
'get-databasehash'(request) {
decryptRequest(request);
const firstFile = appModel.files.firstActiveKdbxFile();
if (firstFile?.defaultGroupHash) {
return encryptResponse(request, {
2021-04-07 22:04:21 +02:00
hash: firstFile.defaultGroupHash,
2021-04-11 14:53:56 +02:00
success: 'true',
version: getVersion(request),
2021-04-10 23:35:42 +02:00
...(isKeeWebConnect(request)
? {
hashes: appModel.files
.filter((file) => file.active && !file.backend)
.map((file) => file.defaultGroupHash)
}
: undefined)
});
} else {
2021-04-11 15:04:04 +02:00
throw new Error(ErrorMessages.noOpenFiles);
}
2021-04-07 22:49:23 +02:00
},
'generate-password'(request) {
const password = PasswordGenerator.generate(GeneratorPresets.browserExtensionPreset);
return encryptResponse(request, {
2021-04-10 23:35:42 +02:00
version: getVersion(request),
success: 'true',
2021-04-11 14:53:56 +02:00
entries: [{ password }]
});
},
2021-04-07 22:49:23 +02:00
'lock-database'(request) {
decryptRequest(request);
2021-04-11 14:53:56 +02:00
if (appModel.files.hasOpenFiles()) {
Events.emit('lock-workspace');
2021-04-07 22:49:23 +02:00
2021-04-11 14:53:56 +02:00
if (Alerts.alertDisplayed) {
BrowserExtensionConnector.focusKeeWeb();
}
2021-04-11 14:53:56 +02:00
return encryptResponse(request, {
success: 'true',
version: getVersion(request)
});
} else {
2021-04-11 15:04:04 +02:00
throw new Error(ErrorMessages.noOpenFiles);
2021-04-11 14:53:56 +02:00
}
2021-04-06 20:10:42 +02:00
}
};
const BrowserExtensionConnector = {
2021-04-06 21:27:58 +02:00
enabled: false,
init(model) {
appModel = model;
2021-04-06 21:27:58 +02:00
this.browserWindowMessage = this.browserWindowMessage.bind(this);
this.fileOpened = this.fileOpened.bind(this);
this.oneFileClosed = this.oneFileClosed.bind(this);
this.allFilesClosed = this.allFilesClosed.bind(this);
2021-04-06 20:10:42 +02:00
AppSettingsModel.on('change:browserExtension', (model, enabled) => {
2021-04-06 21:27:58 +02:00
this.enabled = enabled;
2021-04-06 20:10:42 +02:00
if (enabled) {
this.start();
} else {
this.stop();
}
});
if (AppSettingsModel.browserExtension) {
2021-04-06 21:27:58 +02:00
this.enabled = true;
2021-04-06 20:10:42 +02:00
this.start();
}
},
start() {
if (Launcher) {
this.startDesktopAppListener();
} else {
2021-04-06 20:10:42 +02:00
this.startWebMessageListener();
}
2021-04-06 21:27:58 +02:00
Events.on('file-opened', this.fileOpened);
Events.on('one-file-closed', this.oneFileClosed);
Events.on('all-files-closed', this.allFilesClosed);
2021-04-11 10:17:02 +02:00
logger.info('Started');
2021-04-06 20:10:42 +02:00
},
stop() {
if (Launcher) {
this.stopDesktopAppListener();
} else {
2021-04-06 20:10:42 +02:00
this.stopWebMessageListener();
}
2021-04-06 21:27:58 +02:00
Events.off('file-opened', this.fileOpened);
Events.off('one-file-closed', this.oneFileClosed);
Events.off('all-files-closed', this.allFilesClosed);
2021-04-11 10:17:02 +02:00
logger.info('Stopped');
2021-04-06 20:10:42 +02:00
},
startWebMessageListener() {
window.addEventListener('message', this.browserWindowMessage);
},
stopWebMessageListener() {
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) => {
2021-04-11 10:17:02 +02:00
logger.info('New connection');
this.connectedSockets.push(socket);
this.connectedSocketState.set(socket, {});
this.checkSocketIdentity(socket);
socket.on('data', (data) => this.onSocketData(socket, data));
socket.on('close', () => this.onSocketClose(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();
},
checkSocketIdentity(socket) {
const state = this.connectedSocketState.get(socket);
if (!state) {
return;
}
// TODO: check the process
state.active = true;
this.processPendingSocketData(socket);
},
onSocketClose(socket) {
2021-04-11 12:48:58 +02:00
const state = this.connectedSocketState.get(socket);
if (state?.clientId) {
connectedClients.delete(state.clientId);
}
this.connectedSocketState.delete(socket);
2021-04-11 12:48:58 +02:00
this.connectedSockets = this.connectedSockets.filter((s) => s !== socket);
logger.info('Connection closed', state?.clientId);
},
onSocketData(socket, data) {
if (data.byteLength > MaxIncomingDataLength) {
2021-04-11 10:17:02 +02:00
logger.warn('Too many bytes rejected', data.byteLength);
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.active) {
this.processPendingSocketData(socket);
}
},
processPendingSocketData(socket) {
const state = this.connectedSocketState.get(socket);
if (!state) {
return;
}
while (state.pendingData) {
if (state.pendingData.length < 4) {
return;
}
2021-04-11 10:17:02 +02:00
const lengthBuffer = kdbxweb.ByteUtils.arrayToBuffer(state.pendingData.slice(0, 4));
const length = new Uint32Array(lengthBuffer)[0];
if (length > MaxIncomingDataLength) {
2021-04-11 10:17:02 +02:00
logger.warn('Large message rejected', 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 {
2021-04-11 10:17:02 +02:00
logger.warn('Failed to parse message', str);
socket.destroy();
return;
}
2021-04-11 12:48:58 +02:00
const clientId = request?.clientID;
if (!clientId) {
logger.warn('Empty client ID in request', request);
socket.destroy();
return;
}
if (!state.clientId) {
state.clientId = clientId;
} else if (state.clientId !== clientId) {
logger.warn(`Changing client ID is not allowed: ${state.clientId} => ${clientId}`);
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) {
2021-04-11 15:04:04 +02:00
response = this.errorToResponse(e, request);
}
if (response) {
this.sendSocketResponse(socket, response);
}
}
},
2021-04-06 20:10:42 +02:00
browserWindowMessage(e) {
if (e.origin !== location.origin) {
return;
}
if (e.source !== window) {
return;
}
if (e?.data?.kwConnect !== 'request') {
return;
}
let response;
try {
const handler = ProtocolHandlers[e.data.action];
if (!handler) {
throw new Error(`Handler not found: ${e.data.action}`);
}
response = handler(e.data) || {};
} catch (e) {
2021-04-11 15:04:04 +02:00
response = this.errorToResponse(e, e.data);
2021-04-06 20:10:42 +02:00
}
if (response) {
this.sendWebResponse(response);
2021-04-06 20:10:42 +02:00
}
2021-04-06 21:27:58 +02:00
},
2021-04-11 15:04:04 +02:00
errorToResponse(e, request) {
return {
action: request.action,
error: e.message || 'Unknown error',
code: ErrorCode[e.message] ?? 0
};
},
sendWebResponse(response) {
2021-04-06 21:27:58 +02:00
response.kwConnect = 'response';
postMessage(response, window.location.origin);
},
sendSocketResponse(socket, response) {
const responseData = Buffer.from(JSON.stringify(response));
2021-04-11 10:17:02 +02:00
const lengthBuf = kdbxweb.ByteUtils.arrayToBuffer(
new Uint32Array([responseData.byteLength])
);
const lengthBytes = Buffer.from(lengthBuf);
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);
}
},
2021-04-06 21:27:58 +02:00
fileOpened() {
this.sendEvent({ action: 'database-unlocked' });
2021-04-06 21:27:58 +02:00
},
oneFileClosed() {
this.sendEvent({ action: 'database-locked' });
if (appModel.files.hasOpenFiles()) {
this.sendEvent({ action: 'database-unlocked' });
2021-04-06 21:27:58 +02:00
}
},
allFilesClosed() {
this.sendEvent({ action: 'database-locked' });
},
focusKeeWeb() {
2021-04-11 10:17:02 +02:00
logger.debug('Focus KeeWeb');
if (Launcher) {
Launcher.showMainWindow();
} else {
this.sendEvent({ action: 'attention-required' });
}
2021-04-06 20:10:42 +02:00
}
};
export { BrowserExtensionConnector };