diff --git a/keepassxc-browser/background/client.js b/keepassxc-browser/background/client.js index fe6800d..4a5352d 100644 --- a/keepassxc-browser/background/client.js +++ b/keepassxc-browser/background/client.js @@ -65,6 +65,17 @@ const messageBuffer = { } } + return false; + }, + + matchAndRemoveV2(msg) { + for (let i = 0; i < this.buffer.length; ++i) { + if (msg?.requestID === this.buffer[i].requestID) { + this.buffer.splice(i, 1); + return true; + } + } + return false; } }; @@ -86,7 +97,7 @@ keepassClient.sendNativeMessage = function(request, enableTimeout = false, timeo const isNotificationOrError = !msg.nonce && request.requestID === msg.requestID; // Only resolve a matching response or a notification (without nonce) - if (isNotificationOrError || messageBuffer.matchAndRemove(msg)) { + if (isNotificationOrError || messageBuffer.matchAndRemoveV2(msg)) { port.removeListener(handler); if (enableTimeout) { clearTimeout(timeout); @@ -175,6 +186,118 @@ keepassClient.sendMessage = async function(kpAction, tab, messageData, nonce, en return keepassClient.handleResponse(response, incrementedNonce, tab); }; +//-------------------------------------------------------------------------- +// Protocol V2 +//-------------------------------------------------------------------------- + +keepassClient.sendNativeMessageV2 = function(requestAction, request, enableTimeout = false, timeoutValue) { + return new Promise((resolve, reject) => { + let timeout; + const ev = keepassClient.nativePort.onMessage; + + const listener = ((port) => { + const handler = (msg) => { + if (msg && msg?.requestID === request.requestID) { + // Only resolve a matching response + if (messageBuffer.matchAndRemove(msg)) { + port.removeListener(handler); + if (enableTimeout) { + clearTimeout(timeout); + } + + resolve(msg); + return; + } + } + }; + return handler; + })(ev); + ev.addListener(listener); + + const messageTimeout = timeoutValue || keepassClient.messageTimeout; + + // Handle timeouts + if (enableTimeout) { + timeout = setTimeout(() => { + const errorMessage = { + action: requestAction, + error: kpErrors.getError(kpErrors.TIMEOUT_OR_NOT_CONNECTED), + errorCode: kpErrors.TIMEOUT_OR_NOT_CONNECTED + }; + keepass.isKeePassXCAvailable = false; + ev.removeListener(listener.handler); + resolve(errorMessage); + }, messageTimeout); + } + + // Store the request to the buffer + messageBuffer.addMessage(request); + + // Send the request + if (keepassClient.nativePort) { + keepassClient.nativePort.postMessage(request); + } + }); +}; + +keepassClient.sendMessageV2 = async function(kpAction, tab, messageData, nonce, enableTimeout = false, triggerUnlock = false) { + const request = keepassClient.buildRequestV2(keepassClient.encrypt(messageData, nonce), nonce, keepass.clientID, messageData.requestID, triggerUnlock); + const response = await keepassClient.sendNativeMessageV2(kpAction, request, enableTimeout); + const incrementedNonce = keepassClient.incrementedNonce(nonce); + + return keepassClient.handleResponseV2(response, incrementedNonce, tab); +}; + +keepassClient.buildRequestV2 = function(encryptedMessage, nonce, clientID, requestID, triggerUnlock = false) { + const request = { + message: encryptedMessage, + nonce: nonce, + clientID: clientID, + requestID: requestID + }; + + if (triggerUnlock) { + request.triggerUnlock = 'true'; + } + + return request; +}; + +keepassClient.handleResponseV2 = function(response, incrementedNonce, tab) { + if (response.message && response.nonce) { + const res = keepassClient.decrypt(response.message, response.nonce); + if (!res) { + keepass.handleError(tab, kpErrors.CANNOT_DECRYPT_MESSAGE); + return undefined; + } + + const message = nacl.util.encodeUTF8(res); + const parsed = JSON.parse(message); + + if (keepassClient.verifyResponseV2(parsed, incrementedNonce)) { + return parsed; + } + } else if (response.error && response.errorCode) { + keepass.handleError(tab, response.errorCode, response.error); + } + + return undefined; +}; + +keepassClient.verifyResponseV2 = function(response, nonce) { + if (!keepassClient.checkNonceLength(response.nonce)) { + logError('Incorrect nonce length'); + return false; + } + + if (response.nonce !== nonce) { + logError('Nonce compare failed'); + return false; + } + + return true; +}; + //-------------------------------------------------------------------------- // Utils //-------------------------------------------------------------------------- diff --git a/keepassxc-browser/background/keepass.js b/keepassxc-browser/background/keepass.js index ee15d32..b74b7fe 100755 --- a/keepassxc-browser/background/keepass.js +++ b/keepassxc-browser/background/keepass.js @@ -16,6 +16,7 @@ keepass.cacheTimeout = 30 * 1000; // Milliseconds keepass.databaseHash = ''; keepass.previousDatabaseHash = ''; keepass.reconnectLoop = null; +keepass.protocolV2 = false; const kpActions = { SET_LOGIN: 'set-login', @@ -31,7 +32,10 @@ const kpActions = { GET_DATABASE_GROUPS: 'get-database-groups', CREATE_NEW_GROUP: 'create-new-group', GET_TOTP: 'get-totp', - REQUEST_AUTOTYPE: 'request-autotype' + REQUEST_AUTOTYPE: 'request-autotype', + // V2 + CREATE_CREDENTIALS: 'create-credentials', + GET_CREDENTIALS: 'get-credentials' }; browser.storage.local.get({ 'latestKeePassXC': { 'version': '', 'lastChecked': null }, 'keyRing': {} }).then((item) => { @@ -146,7 +150,7 @@ keepass.retrieveCredentials = async function(tab, args = []) { const response = await keepassClient.sendMessage(kpAction, tab, messageData, nonce); if (response) { - entries = removeDuplicateEntries(response.entries); + entries = keepass.removeDuplicateEntries(response.entries); keepass.updateLastUsed(keepass.databaseHash); if (entries.length === 0) { @@ -783,6 +787,7 @@ keepass.isAssociated = function() { keepass.setcurrentKeePassXCVersion = function(version) { if (version) { keepass.currentKeePassXC = version; + keepass.protocolV2 = keepass.compareVersion('2.8.0', version); } }; @@ -907,7 +912,7 @@ keepass.compareVersion = function(minimum, current, canBeEqual = true) { return (canBeEqual ? (min <= cur) : (min < cur)); }; -const removeDuplicateEntries = function(arr) { +keepass.removeDuplicateEntries = function(arr) { const newArray = []; for (const a of arr) { diff --git a/keepassxc-browser/background/protocol.js b/keepassxc-browser/background/protocol.js new file mode 100644 index 0000000..1d1cf76 --- /dev/null +++ b/keepassxc-browser/background/protocol.js @@ -0,0 +1,412 @@ +'use strict'; + +const protocol = {}; + +protocol.associate = async function(tab, args = []) { + console.log('associate'); + + if (!keepass.isKeePassXCAvailable) { + return AssociatedAction.NOT_ASSOCIATED; + } + + try { + keepass.clearErrorMessage(tab); + + const kpAction = kpActions.ASSOCIATE; + const key = nacl.util.encodeBase64(keepass.keyPair.publicKey); + const nonce = keepassClient.getNonce(); + const idKeyPair = nacl.box.keyPair(); + const idKey = nacl.util.encodeBase64(idKeyPair.publicKey); + + const messageData = { + action: kpAction, + key: key, + idKey: idKey + }; + + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce, false, true); + if (response && response.id && response.hash) { + keepass.setCryptoKey(response.id, key); // Save the new identification public key as id key for the database + + browserAction.show(tab); + return AssociatedAction.NEW_ASSOCIATION; + } + + keepass.handleError(tab, kpErrors.ASSOCIATION_FAILED); + return AssociatedAction.NOT_ASSOCIATED; + } catch (err) { + logError(`associate failed: ${err}`); + } + + return AssociatedAction.NOT_ASSOCIATED; +}; + +protocol.changePublicKeys = async function(tab, enableTimeout = false, connectionTimeout) { + console.log('change-public-keys'); + + if (!keepass.isConnected) { + keepass.handleError(tab, kpErrors.TIMEOUT_OR_NOT_CONNECTED); + return false; + } + + const kpAction = kpActions.CHANGE_PUBLIC_KEYS; + const key = nacl.util.encodeBase64(keepass.keyPair.publicKey); + const [ nonce, incrementedNonce ] = keepassClient.getNonces(); + keepass.clientID = nacl.util.encodeBase64(nacl.randomBytes(keepassClient.keySize)); + + const request = { + action: kpAction, + publicKey: key, + nonce: nonce, + clientID: keepass.clientID + }; + + try { + const response = await keepassClient.sendNativeMessageV2(kpAction, request, enableTimeout, connectionTimeout); + keepass.setcurrentKeePassXCVersion(response.version); + + const verified = keepass.protocolV2 + ? keepassClient.verifyResponseV2(response, incrementedNonce) + : keepassClient.verifyKeyResponse(response, key, incrementedNonce); + if (!response?.publicKey || !verified) { + if (tab && page.tabs[tab.id]) { + keepass.handleError(tab, kpErrors.KEY_CHANGE_FAILED); + } + + keepass.updateDatabaseHashToContent(); + return false; + } + + keepass.serverPublicKey = nacl.util.decodeBase64(response.publicKey); + keepass.isKeePassXCAvailable = true; + console.log(`${EXTENSION_NAME}: Server public key: ${nacl.util.encodeBase64(keepass.serverPublicKey)}`); + return true; + } catch (err) { + logError(`changePublicKeys failed: ${err}`); + return false; + } +}; + +protocol.createCredentials = async function(tab, args = []) { + console.log('create-credentials'); + + const [ username, password, url, group, groupUuid ] = args; + return protocol.updateCredentials(tab, [ null, username, password, url, group, groupUuid ]); +}; + +protocol.createNewGroup = async function(tab, args = []) { + console.log('create-new-group'); + + if (!keepass.isConnected) { + return []; + } + + keepass.clearErrorMessage(tab); + + const [ groupName ] = args; + const kpAction = kpActions.CREATE_NEW_GROUP; + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction, + groupName: groupName + }; + + try { + // TODO: Handle errors + const response = await keepassClient.sendMessage(kpAction, tab, messageData, nonce); + if (response) { + keepass.updateLastUsed(keepass.databaseHash); // ? + return response; + } else { + logError('getDatabaseGroups rejected'); + } + + browserAction.showDefault(tab); + return []; + } catch (err) { + logError(`createNewGroup failed: ${err}`); + return []; + } +}; + +protocol.generatePassword = async function(tab, args = []) { + console.log('generate-password'); + + if (!keepass.isConnected) { + return []; + } + + if (!keepass.compareVersion(keepass.requiredKeePassXC, keepass.currentKeePassXC)) { + return []; + } + + let password; + const kpAction = kpActions.GENERATE_PASSWORD; + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction, + nonce: nonce, + clientID: keepass.clientID, + requestID: keepassClient.getRequestId() + }; + + try { + const response = await keepassClient.sendMessage(kpAction, tab, messageData, nonce); + if (response) { + password = response.entries ?? response.password; + keepass.updateLastUsed(keepass.databaseHash); // ? + } else { + logError('generatePassword rejected'); + } + + return password; + } catch (err) { + logError(`generatePassword failed: ${err}`); + return []; + } +}; + +protocol.getCredentials = async function(tab, args = []) { + console.log('get-credentials'); + + if (!keepass.isConnected) { + return []; + } + + keepass.clearErrorMessage(tab); + + const [ url, submiturl, triggerUnlock = false, httpAuth = false ] = args; + let entries = []; + const kpAction = kpActions.GET_CREDENTIALS; + const nonce = keepassClient.getNonce(); + const [ dbid ] = keepass.getCryptoKey(); + + const messageData = { + action: kpAction, + id: dbid, + url: url, + keys: keepass.getKeys() + }; + + if (submiturl) { + messageData.submitUrl = submiturl; + } + + if (httpAuth) { + messageData.httpAuth = 'true'; + } + + try { + // TODO: Handle errors + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce, false, triggerUnlock); + if (response) { + entries = keepass.removeDuplicateEntries(response.entries); + keepass.updateLastUsed(keepass.databaseHash); // What about this? + + if (entries.length === 0) { + // Questionmark-icon is not triggered, so we have to trigger for the normal symbol + browserAction.showDefault(tab); + } + + logDebug(`Found ${entries.length} entries for url ${url}`); + return entries; + } + + browserAction.showDefault(tab); + return []; + } catch (err) { + logError(`retrieveCredentials failed: ${err}`); + return []; + } +}; + +protocol.getDatabaseGroups = async function(tab, args = []) { + console.log('get-database-groups'); + + if (!keepass.isConnected) { + return []; + } + + keepass.clearErrorMessage(tab); + + let groups = []; + const kpAction = kpActions.GET_DATABASE_GROUPS; + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction + }; + + try { + // TODO: Handle errors + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce); + if (response) { + groups = response.groups; + groups.defaultGroup = page.settings.defaultGroup; + groups.defaultGroupAlwaysAsk = page.settings.defaultGroupAlwaysAsk; + keepass.updateLastUsed(keepass.databaseHash); // ? + return groups; + } + + browserAction.showDefault(tab); + return []; + } catch (err) { + logError(`getDatabaseGroups failed: ${err}`); + return []; + } +}; + +// Obsolete? This should be checked inside KeePassXC +protocol.getDatabaseStatuses = async function(tab, args = []) { + console.log('get-database-statuses'); +}; + +protocol.getTotp = async function(tab, args = []) { + console.log('get-totp'); + + if (!keepass.isConnected) { + return []; + } + + const [ uuid, oldTotp ] = args; + if (!keepass.compareVersion('2.6.1', keepass.currentKeePassXC, true)) { + return oldTotp; + } + + const kpAction = kpActions.GET_TOTP; + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction, + uuid: uuid + }; + + try { + const response = await keepassClient.sendMessage(kpAction, tab, messageData, nonce); + if (response) { + keepass.updateLastUsed(keepass.databaseHash); + return response.totp; + } + + return; + } catch (err) { + logError(`getTotp failed: ${err}`); + } +}; + +protocol.lockDatabase = async function(tab, args = []) { + console.log('lock-database'); + + if (!keepass.isConnected) { + keepass.handleError(tab, kpErrors.TIMEOUT_OR_NOT_CONNECTED); + return false; + } + + const kpAction = kpActions.LOCK_DATABASE; + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction + }; + + try { + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce); + if (response) { + //keepass.isDatabaseClosed = true; // ? + keepass.updateDatabase(); + + // Display error message in the popup + keepass.handleError(tab, kpErrors.DATABASE_NOT_OPENED); + return true; + } else { + //keepass.isDatabaseClosed = true; // ? + } + + return false; + } catch (err) { + logError(`lockDatabase failed: ${err}`); + return false; + } +}; + +protocol.requestAutotype = async function(tab, args = []) { + console.log('request-autotype'); + + if (!keepass.isConnected) { + keepass.handleError(tab, kpErrors.TIMEOUT_OR_NOT_CONNECTED); + return false; + } + + const kpAction = kpActions.REQUEST_AUTOTYPE; + const nonce = keepassClient.getNonce(); + const search = getTopLevelDomainFromUrl(args[0]); + + const messageData = { + action: kpAction, + search: search + }; + + try { + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce); + return response?.result; + } catch (err) { + logError(`requestAutotype failed: ${err}`); + return false; + } +}; + +protocol.updateCredentials = async function(tab, args = []) { + console.log('update-credentials'); + + if (!keepass.isConnected) { + return []; + } + + const [ entryId, username, password, url, group, groupUuid ] = args; + const kpAction = kpActions.CREATE_CREDENTIALS; + const [ dbid ] = keepass.getCryptoKey(); + const nonce = keepassClient.getNonce(); + + const messageData = { + action: kpAction, + id: dbid, + login: username, + password: password, + url: url, + submitUrl: url + }; + + if (entryId) { + messageData.uuid = entryId; + } + + if (!entryId && page.settings.downloadFaviconAfterSave) { + messageData.downloadFavicon = true; + } + + if (group && groupUuid) { + messageData.group = group; + messageData.groupUuid = groupUuid; + } + + try { + // TODO: Check response messages + const response = await keepassClient.sendMessageV2(kpAction, tab, messageData, nonce); + if (response) { + // KeePassXC versions lower than 2.5.0 will have an empty parsed.error + let successMessage = response.error; + if (response?.result === true || response.error === '') { + successMessage = entryId ? 'updated' : 'created'; + } + + return successMessage; + } else { + return 'error'; + } + } catch (err) { + logError(`updateCredentials failed: ${err}`); + return []; + } +}; diff --git a/keepassxc-browser/content/username-field.js b/keepassxc-browser/content/username-field.js index 8835d02..377b181 100644 --- a/keepassxc-browser/content/username-field.js +++ b/keepassxc-browser/content/username-field.js @@ -134,6 +134,7 @@ const iconClicked = async function(field, icon) { if (kpxc.databaseState !== DatabaseState.UNLOCKED) { // Triggers database unlock await sendMessage('page_set_manual_fill', ManualFill.BOTH); + // TODO: Replace with open-database? await sendMessage('get_database_hash', [ false, true ]); // Set triggerUnlock to true field.focus(); } diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 008cb8e..9047dbb 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -43,7 +43,8 @@ "background/browserAction.js", "background/page.js", "background/event.js", - "background/init.js" + "background/init.js", + "background/protocol.js" ] }, "content_scripts": [