keeweb/app/scripts/comp/extension/protocol-impl.js

535 lines
15 KiB
JavaScript
Raw Normal View History

2021-04-06 20:10:42 +02:00
import kdbxweb from 'kdbxweb';
2021-04-06 21:27:58 +02:00
import { Events } from 'framework/events';
2021-04-06 20:10:42 +02:00
import { Launcher } from 'comp/launcher';
import { box as tweetnaclBox } from 'tweetnacl';
import { PasswordGenerator } from 'util/generators/password-generator';
import { GeneratorPresets } from 'comp/app/generator-presets';
import { Alerts } from 'comp/ui/alerts';
import { Locale } from 'util/locale';
import { RuntimeInfo } from 'const/runtime-info';
import { KnownAppVersions } from 'const/known-app-versions';
2021-04-25 13:46:59 +02:00
import { ExtensionConnectView } from 'views/extension/extension-connect-view';
import { RuntimeDataModel } from 'models/runtime-data-model';
2021-04-26 12:08:30 +02:00
import { AppSettingsModel } from 'models/app-settings-model';
2021-04-25 14:09:54 +02:00
import { Timeouts } from 'const/timeouts';
2021-04-11 10:17:02 +02:00
2021-04-11 17:22:58 +02:00
const KeeWebAssociationId = 'KeeWeb';
const KeeWebHash = '398d9c782ec76ae9e9877c2321cbda2b31fc6d18ccf0fed5ca4bd746bab4d64a'; // sha256('KeeWeb')
2021-04-06 20:10:42 +02:00
const Errors = {
noOpenFiles: {
message: Locale.extensionErrorNoOpenFiles,
code: '1'
},
userRejected: {
message: Locale.extensionErrorUserRejected,
code: '6'
}
};
const connectedClients = new Map();
let logger;
let appModel;
let sendEvent;
2021-04-12 23:42:42 +02:00
function setupListeners() {
Events.on('file-opened', () => {
sendEvent({ action: 'database-unlocked' });
});
Events.on('one-file-closed', () => {
if (!appModel.files.hasOpenFiles()) {
sendEvent({ action: 'database-locked' });
}
});
Events.on('all-files-closed', () => {
sendEvent({ action: 'database-locked' });
});
}
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);
logger.debug('Extension -> KeeWeb -> (decrypted)', payload);
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) {
logger.debug('KeeWeb -> Extension (decrypted)', 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
};
}
function makeError(def) {
const e = new Error(def.message);
e.code = def.code;
return e;
}
2021-04-11 17:22:58 +02:00
function ensureAtLeastOneFileIsOpen() {
if (!appModel.files.hasOpenFiles()) {
throw makeError(Errors.noOpenFiles);
2021-04-11 17:22:58 +02:00
}
}
async function checkContentRequestPermissions(request) {
if (!appModel.files.hasOpenFiles()) {
if (AppSettingsModel.extensionFocusIfLocked) {
try {
focusKeeWeb();
await appModel.unlockAnyFile(
'extensionUnlockMessage',
Timeouts.KeeWebConnectRequest
);
} catch {
Launcher?.hideApp();
throw makeError(Errors.noOpenFiles);
}
} else {
throw makeError(Errors.noOpenFiles);
}
}
const client = getClient(request);
2021-04-25 13:46:59 +02:00
if (client.permissions) {
return;
2021-04-11 17:22:58 +02:00
}
return new Promise((resolve, reject) => {
if (Alerts.alertDisplayed) {
return reject(new Error(Locale.extensionErrorAlertDisplayed));
}
focusKeeWeb();
2021-04-25 13:46:59 +02:00
const config = RuntimeDataModel.extensionConnectConfig;
const files = appModel.files.map((f) => ({
id: f.id,
name: f.name,
checked: !config || config.allFiles || config.files.includes(f.id)
}));
if (!files.some((f) => f.checked)) {
for (const f of files) {
f.checked = true;
}
}
2021-04-25 13:46:59 +02:00
2021-04-25 20:38:49 +02:00
const extensionName = client.connection.appName
? `${client.connection.extensionName} (${client.connection.appName})`
: client.connection.extensionName;
2021-04-25 13:46:59 +02:00
const extensionConnectView = new ExtensionConnectView({
2021-04-25 20:38:49 +02:00
extensionName,
2021-04-25 13:46:59 +02:00
identityVerified: !Launcher,
files,
allFiles: config?.allFiles ?? true,
askGet: config?.askGet || 'multiple'
});
2021-04-25 14:09:54 +02:00
let inactivityTimer = 0;
const alert = Alerts.alert({
2021-04-25 13:46:59 +02:00
header: Locale.extensionConnectHeader,
icon: 'exchange-alt',
view: extensionConnectView,
wide: true,
opaque: true,
buttons: [Alerts.buttons.allow, Alerts.buttons.deny],
success: () => {
2021-04-25 14:09:54 +02:00
clearTimeout(inactivityTimer);
2021-04-25 13:46:59 +02:00
RuntimeDataModel.extensionConnectConfig = extensionConnectView.config;
client.permissions = extensionConnectView.config;
2021-04-25 18:08:16 +02:00
Events.emit('browser-extension-sessions-changed');
2021-04-25 13:46:59 +02:00
resolve();
},
2021-04-25 14:09:54 +02:00
cancel: () => {
2021-04-25 18:08:16 +02:00
client.permissionsDenied = true;
2021-04-25 14:09:54 +02:00
clearTimeout(inactivityTimer);
2021-04-25 18:08:16 +02:00
Events.emit('browser-extension-sessions-changed');
2021-04-25 14:09:54 +02:00
reject(makeError(Errors.userRejected));
}
2021-04-25 13:46:59 +02:00
});
2021-04-25 14:09:54 +02:00
inactivityTimer = setTimeout(() => {
alert.closeWithResult('');
}, Timeouts.KeeWebConnectRequest);
}).catch((e) => {
Launcher?.hideApp();
throw e;
});
2021-04-11 17:22:58 +02:00
}
function getAvailableFiles(request) {
const client = getClient(request);
if (!client.permissions) {
return;
}
const files = appModel.files.filter(
(file) =>
file.active &&
(client.permissions.allFiles || client.permissions.files.includes(file.id))
);
if (!files.length) {
throw makeError(Errors.noOpenFiles);
}
return files;
}
2021-04-10 23:35:42 +02:00
function getVersion(request) {
const extensionName = getClient(request).connection.extensionName;
2021-04-10 23:35:42 +02:00
return extensionName ? RuntimeInfo.version : KnownAppVersions.KeePassXC;
}
function isKeeWebConnect(request) {
2021-04-25 13:46:59 +02:00
return getClient(request).connection.extensionName === 'KeeWeb Connect';
2021-04-10 23:35:42 +02:00
}
function focusKeeWeb() {
logger.debug('Focus KeeWeb');
if (Launcher) {
Launcher.showMainWindow();
} else {
sendEvent({ action: 'attention-required' });
}
}
2021-04-11 14:53:56 +02:00
2021-04-06 20:10:42 +02:00
const ProtocolHandlers = {
'ping'({ data }) {
return { data };
},
'change-public-keys'(request, connection) {
let { publicKey, 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-25 18:08:16 +02:00
const stats = {
2021-04-26 12:22:35 +02:00
connectedDate: new Date(),
passwordsRead: 0,
passwordsWritten: 0
2021-04-25 18:08:16 +02:00
};
connectedClients.set(clientId, { connection, publicKey, version, keys, stats });
Events.emit('browser-extension-sessions-changed');
2021-04-06 20:10:42 +02:00
logger.info('New client key created', clientId, 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);
2021-04-11 17:22:58 +02:00
ensureAtLeastOneFileIsOpen();
2021-04-11 17:22:58 +02:00
return encryptResponse(request, {
hash: KeeWebHash,
success: 'true',
version: getVersion(request)
});
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 17:22:58 +02:00
ensureAtLeastOneFileIsOpen();
2021-04-07 22:49:23 +02:00
2021-04-11 17:22:58 +02:00
Events.emit('lock-workspace');
2021-04-11 17:22:58 +02:00
if (Alerts.alertDisplayed) {
focusKeeWeb();
2021-04-11 14:53:56 +02:00
}
2021-04-11 17:22:58 +02:00
return encryptResponse(request, {
success: 'true',
version: getVersion(request)
});
},
'associate'(request) {
decryptRequest(request);
ensureAtLeastOneFileIsOpen();
return encryptResponse(request, {
success: 'true',
version: getVersion(request),
hash: KeeWebHash,
id: KeeWebAssociationId
});
},
'test-associate'(request) {
const payload = decryptRequest(request);
// ensureAtLeastOneFileIsOpen();
2021-04-12 23:05:57 +02:00
if (payload.id !== KeeWebAssociationId) {
throw makeError(Errors.noOpenFiles);
}
2021-04-11 17:22:58 +02:00
return encryptResponse(request, {
success: 'true',
version: getVersion(request),
hash: KeeWebHash,
id: payload.id
});
},
async 'get-logins'(request) {
decryptRequest(request);
await checkContentRequestPermissions(request);
throw new Error('Not implemented');
},
async 'get-totp'(request) {
decryptRequest(request);
await checkContentRequestPermissions(request);
throw new Error('Not implemented');
},
async 'set-login'(request) {
decryptRequest(request);
await checkContentRequestPermissions(request);
throw new Error('Not implemented');
},
async 'get-database-groups'(request) {
decryptRequest(request);
await checkContentRequestPermissions(request);
2021-04-12 23:05:57 +02:00
const makeGroups = (group) => {
const res = {
name: group.title,
uuid: kdbxweb.ByteUtils.bytesToHex(group.group.uuid.bytes),
children: []
};
for (const subGroup of group.items) {
if (subGroup.matches()) {
res.children.push(makeGroups(subGroup));
}
}
return res;
};
const groups = [];
for (const file of getAvailableFiles(request)) {
2021-04-12 23:05:57 +02:00
for (const group of file.groups) {
groups.push(makeGroups(group));
}
}
return encryptResponse(request, {
success: 'true',
version: getVersion(request),
groups: { groups }
});
},
async 'create-new-group'(request) {
2021-04-12 23:23:22 +02:00
const payload = decryptRequest(request);
await checkContentRequestPermissions(request);
2021-04-12 23:23:22 +02:00
if (!payload.groupName) {
throw new Error('No groupName');
}
// TODO: show file selector
// throw makeError(Errors.userRejected);
const groupNames = payload.groupName
.split('/')
.map((g) => g.trim())
.filter((g) => g);
if (!groupNames.length) {
throw new Error('Empty group path');
}
// TODO: create a new group
throw new Error('Not implemented');
2021-04-12 23:23:22 +02:00
// return encryptResponse(request, {
// success: 'true',
// version: getVersion(request),
// name: groupNames[groupNames.length - 1],
// uuid: kdbxweb.ByteUtils.bytesToHex(appModel.files[0].groups[0].group.uuid.bytes)
// });
2021-04-06 20:10:42 +02:00
}
};
2021-04-19 23:40:57 +02:00
const ProtocolImpl = {
init(vars) {
appModel = vars.appModel;
logger = vars.logger;
sendEvent = vars.sendEvent;
setupListeners();
},
cleanup() {
2021-04-25 18:08:16 +02:00
const wasNotEmpty = connectedClients.size;
2021-04-19 23:40:57 +02:00
connectedClients.clear();
2021-04-25 18:08:16 +02:00
if (wasNotEmpty) {
Events.emit('browser-extension-sessions-changed');
}
2021-04-19 23:40:57 +02:00
},
deleteConnection(connectionId) {
2021-04-25 18:08:16 +02:00
for (const [clientId, client] of connectedClients.entries()) {
2021-04-19 23:40:57 +02:00
if (client.connection.connectionId === connectionId) {
2021-04-25 18:08:16 +02:00
connectedClients.delete(clientId);
2021-04-19 23:40:57 +02:00
}
}
2021-04-25 18:08:16 +02:00
Events.emit('browser-extension-sessions-changed');
2021-04-19 23:40:57 +02:00
},
2021-04-25 19:59:52 +02:00
getClientPermissions(clientId) {
return connectedClients.get(clientId)?.permissions;
},
setClientPermissions(clientId, permissions) {
const client = connectedClients.get(clientId);
if (client?.permissions) {
client.permissions = { ...client.permissions, ...permissions };
}
},
2021-04-19 23:40:57 +02:00
errorToResponse(e, request) {
return {
action: request?.action,
error: e.message || 'Unknown error',
errorCode: e.code || 0
};
},
async handleRequest(request, connectionInfo) {
try {
const handler = ProtocolHandlers[request.action];
if (!handler) {
throw new Error(`Handler not found: ${request.action}`);
}
return await handler(request, connectionInfo);
} catch (e) {
return this.errorToResponse(e, request);
}
2021-04-25 18:08:16 +02:00
},
get sessions() {
return [...connectedClients.entries()]
.map(([clientId, client]) => ({
clientId,
connectionId: client.connection.connectionId,
appName: client.connection.appName,
extensionName: client.connection.extensionName,
connectedDate: client.stats.connectedDate,
passwordsRead: client.stats.passwordsRead,
passwordsWritten: client.stats.passwordsWritten,
permissions: client.permissions,
permissionsDenied: client.permissionsDenied
}))
.sort((x, y) => y.connectedDate - x.connectedDate);
2021-04-19 23:40:57 +02:00
}
};
export { ProtocolImpl };