diff --git a/dist/manifest_chromium.json b/dist/manifest_chromium.json index 3d4978c..0acbf28 100755 --- a/dist/manifest_chromium.json +++ b/dist/manifest_chromium.json @@ -33,7 +33,7 @@ "background": { "service_worker": "background/background_service.js" }, - "content_scripts": [ + "content_scripts": [ { "matches": [ "" @@ -45,7 +45,6 @@ "js": [ "common/browser-polyfill.min.js", "common/global.js", - "common/global_ui.js", "common/sites.js", "content/ui.js", "content/banner.js", @@ -61,11 +60,25 @@ "content/pwgen.js", "content/totp-autocomplete.js", "content/totp-field.js", - "content/username-field.js", - "content/passkeys-utils.js" + "content/username-field.js" ], "run_at": "document_idle", "all_frames": true + }, + { + "matches": [ + "" + ], + "exclude_matches": [ + "*://*/*.xml*", + "file:///*.xml*" + ], + "js": [ + "content/passkeys-inject.js", + "content/passkeys-utils.js" + ], + "run_at": "document_start", + "all_frames": true } ], "commands": { diff --git a/dist/manifest_firefox.json b/dist/manifest_firefox.json index cc61381..90c651c 100644 --- a/dist/manifest_firefox.json +++ b/dist/manifest_firefox.json @@ -73,11 +73,25 @@ "content/pwgen.js", "content/totp-autocomplete.js", "content/totp-field.js", - "content/username-field.js", - "content/passkeys-utils.js" + "content/username-field.js" ], "run_at": "document_idle", "all_frames": true + }, + { + "matches": [ + "" + ], + "exclude_matches": [ + "*://*/*.xml*", + "file:///*.xml*" + ], + "js": [ + "content/passkeys-inject.js", + "content/passkeys-utils.js" + ], + "run_at": "document_start", + "all_frames": true } ], "commands": { diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index 42e25cc..ea20cb7 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -1,9 +1,5 @@ 'use strict'; -const PASSKEYS_NO_LOGINS_FOUND = 15; -const PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21; -const PASSKEYS_WAIT_FOR_LIFETIMER = 30; - // Contains already called method names const _called = {}; _called.automaticRedetectCompleted = false; @@ -863,83 +859,6 @@ kpxc.usePredefinedSites = function(currentLocation) { } }; -// Apply a script to the page for intercepting Passkeys (WebAuthn) requests -kpxc.enablePasskeys = function() { - if (document?.documentElement?.ownerDocument?.contentType !== 'text/html') { - return; - } - - const passkeys = document.createElement('script'); - passkeys.src = browser.runtime.getURL('content/passkeys.js'); - document.documentElement.appendChild(passkeys); - - const startTimer = function(timeout) { - return setTimeout(() => { - throw new DOMException('lifetimeTimer has expired', 'NotAllowedError'); - }, timeout); - }; - - const stopTimer = function(lifetimeTimer) { - if (lifetimeTimer) { - clearTimeout(lifetimeTimer); - } - }; - - const letTimerRunOut = function (errorCode) { - return ( - errorCode === PASSKEYS_WAIT_FOR_LIFETIMER || - errorCode === PASSKEYS_CREDENTIAL_IS_EXCLUDED || - errorCode === PASSKEYS_NO_LOGINS_FOUND - ); - }; - - const sendResponse = async function(command, publicKey, callback) { - const lifetimeTimer = startTimer(publicKey?.timeout); - - const ret = await sendMessage(command, [ publicKey, window.location.origin ]); - if (ret) { - let errorMessage; - if (ret.response && ret.response.errorCode) { - errorMessage = await sendMessage('get_error_message', ret.response.errorCode); - kpxcUI.createNotification('error', errorMessage); - - if (kpxc.settings.passkeysFallback) { - kpxcPasskeysUtils.sendPasskeysResponse(undefined, ret.response?.errorCode, errorMessage); - } else if (letTimerRunOut(ret?.response?.errorCode)) { - return; - } - } - - logDebug('Passkey response', ret.response); - kpxcPasskeysUtils.sendPasskeysResponse(ret.response, ret.response?.errorCode, errorMessage); - stopTimer(lifetimeTimer); - } - }; - - document.addEventListener('kpxc-passkeys-request', async (ev) => { - if (!window.isSecureContext) { - kpxcUI.createNotification('error', tr('errorMessagePasskeysContextIsNotSecure')); - return; - } - - if (ev.detail.action === 'passkeys_create') { - const publicKey = kpxcPasskeysUtils.buildCredentialCreationOptions( - ev.detail.publicKey, - ev.detail.sameOriginWithAncestors, - ); - logDebug('Passkey request', publicKey); - await sendResponse('passkeys_register', publicKey); - } else if (ev.detail.action === 'passkeys_get') { - const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions( - ev.detail.publicKey, - ev.detail.sameOriginWithAncestors, - ); - logDebug('Passkey request', publicKey); - await sendResponse('passkeys_get', publicKey); - } - }); -}; - /** * Content script initialization. */ @@ -964,10 +883,6 @@ const initContentScript = async function() { return; } - if (kpxc.settings.passkeys) { - kpxc.enablePasskeys(); - } - await kpxc.updateDatabaseState(); await kpxc.initCredentialFields(); diff --git a/keepassxc-browser/content/passkeys-inject.js b/keepassxc-browser/content/passkeys-inject.js new file mode 100644 index 0000000..a4ec0c3 --- /dev/null +++ b/keepassxc-browser/content/passkeys-inject.js @@ -0,0 +1,109 @@ +'use strict'; + +const PASSKEYS_NO_LOGINS_FOUND = 15; +const PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21; +const PASSKEYS_WAIT_FOR_LIFETIMER = 30; + +// Apply a script to the page for intercepting Passkeys (WebAuthn) requests +const enablePasskeys = async function() { + const passkeysLogDebug = function(message, extra) { + if (kpxcPasskeysUtils.debugLogging) { + debugLogMessage(message, extra); + } + }; + + const passkeys = document.createElement('script'); + passkeys.src = chrome.runtime.getURL('content/passkeys.js'); + document.documentElement.appendChild(passkeys); + + const startTimer = function(timeout) { + return setTimeout(() => { + throw new DOMException('lifetimeTimer has expired', 'NotAllowedError'); + }, timeout); + }; + + const stopTimer = function(lifetimeTimer) { + if (lifetimeTimer) { + clearTimeout(lifetimeTimer); + } + }; + + const letTimerRunOut = function (errorCode) { + return ( + errorCode === PASSKEYS_WAIT_FOR_LIFETIMER || + errorCode === PASSKEYS_CREDENTIAL_IS_EXCLUDED || + errorCode === PASSKEYS_NO_LOGINS_FOUND + ); + }; + + const sendResponse = async function(command, publicKey, callback) { + const lifetimeTimer = startTimer(publicKey?.timeout); + + const ret = await chrome.runtime.sendMessage({ action: command, args: [ publicKey, window.location.origin ] }); + if (ret) { + let errorMessage; + if (ret.response && ret.response.errorCode) { + errorMessage = await chrome.runtime.sendMessage({ + action: 'get_error_message', + args: ret.response.errorCode, + }); + kpxcUI.createNotification('error', errorMessage); + + if (kpxcPasskeysUtils.passkeysFallback) { + kpxcPasskeysUtils.sendPasskeysResponse(undefined, ret.response?.errorCode, errorMessage); + } else if (letTimerRunOut(ret?.response?.errorCode)) { + return; + } + } + + passkeysLogDebug('Passkey response', ret.response); + kpxcPasskeysUtils.sendPasskeysResponse(ret.response, ret.response?.errorCode, errorMessage); + stopTimer(lifetimeTimer); + } + }; + + document.addEventListener('kpxc-passkeys-request', async (ev) => { + if (!window.isSecureContext) { + kpxcUI.createNotification('error', tr('errorMessagePasskeysContextIsNotSecure')); + return; + } + + if (ev.detail.action === 'passkeys_create') { + const publicKey = kpxcPasskeysUtils.buildCredentialCreationOptions( + ev.detail.publicKey, + ev.detail.sameOriginWithAncestors, + ); + passkeysLogDebug('Passkey request', publicKey); + await sendResponse('passkeys_register', publicKey); + } else if (ev.detail.action === 'passkeys_get') { + const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions( + ev.detail.publicKey, + ev.detail.sameOriginWithAncestors, + ); + passkeysLogDebug('Passkey request', publicKey); + await sendResponse('passkeys_get', publicKey); + } + }); +}; + +const initContent = async () => { + if (document?.documentElement?.ownerDocument?.contentType !== 'text/html' + && document?.documentElement?.ownerDocument?.contentType !== 'application/xhtml+xml' + ) { + return; + } + + const settings = await chrome.runtime.sendMessage({ action: 'load_settings' }); + if (!settings) { + console.log('Error: Cannot load extension settings'); + return; + } + + if (settings.passkeys) { + kpxcPasskeysUtils.debugLogging = settings?.debugLogging; + kpxcPasskeysUtils.passkeysFallback = settings?.passkeysFallback; + enablePasskeys(); + } +}; + +initContent(); diff --git a/keepassxc-browser/content/passkeys-utils.js b/keepassxc-browser/content/passkeys-utils.js index 1556889..b285412 100644 --- a/keepassxc-browser/content/passkeys-utils.js +++ b/keepassxc-browser/content/passkeys-utils.js @@ -46,8 +46,8 @@ const kpxcPasskeysUtils = {}; // Sends response from KeePassXC back to the injected script kpxcPasskeysUtils.sendPasskeysResponse = function(publicKey, errorCode, errorMessage) { const response = errorCode - ? { errorCode: errorCode, errorMessage: errorMessage, fallback: kpxc.settings.passkeysFallback } - : { publicKey: publicKey, fallback: kpxc.settings.passkeysFallback }; + ? { errorCode: errorCode, errorMessage: errorMessage, fallback: kpxcPasskeysUtils?.passkeysFallback } + : { publicKey: publicKey, fallback: kpxcPasskeysUtils?.passkeysFallback }; const details = isFirefox() ? cloneInto(response, document.defaultView) : response; document.dispatchEvent(new CustomEvent('kpxc-passkeys-response', { detail: details })); }; diff --git a/keepassxc-browser/content/passkeys.js b/keepassxc-browser/content/passkeys.js index ef40bb4..4ca7e9c 100644 --- a/keepassxc-browser/content/passkeys.js +++ b/keepassxc-browser/content/passkeys.js @@ -1,145 +1,144 @@ 'use strict'; -const PASSKEYS_ATTESTATION_NOT_SUPPORTED = 20; -const PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21; -const PASSKEYS_REQUEST_CANCELED = 22; -const PASSKEYS_INVALID_USER_VERIFICATION = 23; -const PASSKEYS_EMPTY_PUBLIC_KEY = 24; -const PASSKEYS_INVALID_URL_PROVIDED = 25; -const PASSKEYS_ORIGIN_NOT_ALLOWED = 26; -const PASSKEYS_DOMAIN_IS_NOT_VALID = 27; -const PASSKEYS_DOMAIN_RPID_MISMATCH = 28; -const PASSKEYS_NO_SUPPORTED_ALGORITHMS = 29; -const PASSKEYS_WAIT_FOR_LIFETIMER = 30; -const PASSKEYS_UNKNOWN_ERROR = 31; -const PASSKEYS_INVALID_CHALLENGE = 32; -const PASSKEYS_INVALID_USER_ID = 33; - -const kpxcStringToArrayBuffer = function(str) { - const arr = Uint8Array.from(str, c => c.charCodeAt(0)); - return arr.buffer; -}; - -// From URL encoded base64 string to ArrayBuffer -const kpxcBase64ToArrayBuffer = function(str) { - return kpxcStringToArrayBuffer(window.atob(str?.replaceAll('-', '+').replaceAll('_', '/'))); -}; - -// Wraps response to AuthenticatorAttestationResponse object -const createAttestationResponse = function(publicKey) { - const response = { - attestationObject: kpxcBase64ToArrayBuffer(publicKey.response.attestationObject), - clientDataJSON: kpxcBase64ToArrayBuffer(publicKey.response.clientDataJSON), - getAuthenticatorData: () => kpxcBase64ToArrayBuffer(publicKey.response?.authenticatorData), - getPublicKey: () => null, - getPublicKeyAlgorithm: () => publicKey.response?.publicKeyAlgorithm, - getTransports: () => [ 'internal' ] - }; - return Object.setPrototypeOf(response, AuthenticatorAttestationResponse.prototype); -}; - -// Wraps response to AuthenticatorAssertionResponse object -const createAssertionResponse = function(publicKey) { - const response = { - authenticatorData: kpxcBase64ToArrayBuffer(publicKey.response?.authenticatorData), - clientDataJSON: kpxcBase64ToArrayBuffer(publicKey.response?.clientDataJSON), - signature: kpxcBase64ToArrayBuffer(publicKey.response?.signature), - userHandle: publicKey.response?.userHandle ? kpxcBase64ToArrayBuffer(publicKey.response?.userHandle) : null - }; - - return Object.setPrototypeOf(response, AuthenticatorAssertionResponse.prototype); -}; - -// Wraps public key to PublicKeyCredential object -const createPublicKeyCredential = function(publicKey) { - const authenticatorResponse = publicKey?.response?.attestationObject - ? createAttestationResponse(publicKey) - : createAssertionResponse(publicKey); - const publicKeyCredential = { - authenticatorAttachment: publicKey.authenticatorAttachment, - id: publicKey.id, - rawId: kpxcBase64ToArrayBuffer(publicKey.id), - response: authenticatorResponse, - type: publicKey.type, - clientExtensionResults: () => publicKey?.response?.clientExtensionResults || {}, - getClientExtensionResults: () => publicKey?.response?.clientExtensionResults || {} - }; - - return Object.setPrototypeOf(publicKeyCredential, PublicKeyCredential.prototype); -}; - -// Posts a message to extension's content script and waits for response -const postMessageToExtension = function(request) { - return new Promise((resolve, reject) => { - const ev = document; - - const listener = ((messageEvent) => { - const handler = (msg) => { - if (msg && msg.type === 'kpxc-passkeys-response' && msg.detail) { - messageEvent.removeEventListener('kpxc-passkeys-response', listener); - resolve(msg.detail); - return; - } - }; - return handler; - })(ev); - ev.addEventListener('kpxc-passkeys-response', listener); - - // Send the request - document.dispatchEvent(new CustomEvent('kpxc-passkeys-request', { detail: request })); - }); -}; - -const isSameOriginWithAncestors = function() { - try { - return window.self.origin === window.top.origin; - } catch (err) { - return false; - } -}; - -// Throws errors to a correct exceptions -const throwError = function(errorCode, errorMessage) { - if ((!errorCode && !errorMessage) || errorCode === PASSKEYS_REQUEST_CANCELED) { - // No error or canceled by user. Stop the timer but throw no exception. Fallback with be called instead. - return; - } - - if (errorCode === PASSKEYS_WAIT_FOR_LIFETIMER || errorCode === PASSKEYS_CREDENTIAL_IS_EXCLUDED) { - // Timer handled in the content script - return; - } - - if ([ PASSKEYS_DOMAIN_RPID_MISMATCH, PASSKEYS_DOMAIN_IS_NOT_VALID ].includes(errorCode)) { - throw new DOMException(errorMessage, DOMException.SECURITY_ERR); - } - - if (errorCode === PASSKEYS_NO_SUPPORTED_ALGORITHMS) { - throw new DOMException(errorMessage, DOMException.NOT_SUPPORTED_ERR); - } - - if ([ PASSKEYS_INVALID_CHALLENGE, PASSKEYS_INVALID_USER_ID ].includes(errorCode)) { - throw new TypeError(errorMessage); - } - - if ( - [ - PASSKEYS_ATTESTATION_NOT_SUPPORTED, - PASSKEYS_INVALID_URL_PROVIDED, - PASSKEYS_INVALID_USER_VERIFICATION, - PASSKEYS_EMPTY_PUBLIC_KEY, - PASSKEYS_UNKNOWN_ERROR, - PASSKEYS_ORIGIN_NOT_ALLOWED, - ].includes(errorCode) - ) { - throw new DOMException(errorMessage, 'NotAllowedError'); - } - - throw new DOMException(errorMessage, 'UnknownError'); -}; - - (async () => { + const PASSKEYS_ATTESTATION_NOT_SUPPORTED = 20; + const PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21; + const PASSKEYS_REQUEST_CANCELED = 22; + const PASSKEYS_INVALID_USER_VERIFICATION = 23; + const PASSKEYS_EMPTY_PUBLIC_KEY = 24; + const PASSKEYS_INVALID_URL_PROVIDED = 25; + const PASSKEYS_ORIGIN_NOT_ALLOWED = 26; + const PASSKEYS_DOMAIN_IS_NOT_VALID = 27; + const PASSKEYS_DOMAIN_RPID_MISMATCH = 28; + const PASSKEYS_NO_SUPPORTED_ALGORITHMS = 29; + const PASSKEYS_WAIT_FOR_LIFETIMER = 30; + const PASSKEYS_UNKNOWN_ERROR = 31; + const PASSKEYS_INVALID_CHALLENGE = 32; + const PASSKEYS_INVALID_USER_ID = 33; + + const kpxcStringToArrayBuffer = function(str) { + const arr = Uint8Array.from(str, c => c.charCodeAt(0)); + return arr.buffer; + }; + + // From URL encoded base64 string to ArrayBuffer + const kpxcBase64ToArrayBuffer = function(str) { + return kpxcStringToArrayBuffer(window.atob(str?.replaceAll('-', '+').replaceAll('_', '/'))); + }; + + // Wraps response to AuthenticatorAttestationResponse object + const createAttestationResponse = function(publicKey) { + const response = { + attestationObject: kpxcBase64ToArrayBuffer(publicKey.response.attestationObject), + clientDataJSON: kpxcBase64ToArrayBuffer(publicKey.response.clientDataJSON), + getAuthenticatorData: () => kpxcBase64ToArrayBuffer(publicKey.response?.authenticatorData), + getPublicKey: () => null, + getPublicKeyAlgorithm: () => publicKey.response?.publicKeyAlgorithm, + getTransports: () => [ 'internal' ] + }; + return Object.setPrototypeOf(response, AuthenticatorAttestationResponse.prototype); + }; + + // Wraps response to AuthenticatorAssertionResponse object + const createAssertionResponse = function(publicKey) { + const response = { + authenticatorData: kpxcBase64ToArrayBuffer(publicKey.response?.authenticatorData), + clientDataJSON: kpxcBase64ToArrayBuffer(publicKey.response?.clientDataJSON), + signature: kpxcBase64ToArrayBuffer(publicKey.response?.signature), + userHandle: publicKey.response?.userHandle ? kpxcBase64ToArrayBuffer(publicKey.response?.userHandle) : null + }; + + return Object.setPrototypeOf(response, AuthenticatorAssertionResponse.prototype); + }; + + // Wraps public key to PublicKeyCredential object + const createPublicKeyCredential = function(publicKey) { + const authenticatorResponse = publicKey?.response?.attestationObject + ? createAttestationResponse(publicKey) + : createAssertionResponse(publicKey); + const publicKeyCredential = { + authenticatorAttachment: publicKey.authenticatorAttachment, + id: publicKey.id, + rawId: kpxcBase64ToArrayBuffer(publicKey.id), + response: authenticatorResponse, + type: publicKey.type, + clientExtensionResults: () => publicKey?.response?.clientExtensionResults || {}, + getClientExtensionResults: () => publicKey?.response?.clientExtensionResults || {} + }; + + return Object.setPrototypeOf(publicKeyCredential, PublicKeyCredential.prototype); + }; + + // Posts a message to extension's content script and waits for response + const postMessageToExtension = function(request) { + return new Promise((resolve, reject) => { + const ev = document; + + const listener = ((messageEvent) => { + const handler = (msg) => { + if (msg && msg.type === 'kpxc-passkeys-response' && msg.detail) { + messageEvent.removeEventListener('kpxc-passkeys-response', listener); + resolve(msg.detail); + return; + } + }; + return handler; + })(ev); + ev.addEventListener('kpxc-passkeys-response', listener); + + // Send the request + document.dispatchEvent(new CustomEvent('kpxc-passkeys-request', { detail: request })); + }); + }; + + const isSameOriginWithAncestors = function() { + try { + return window.self.origin === window.top.origin; + } catch (err) { + return false; + } + }; + + // Throws errors to a correct exceptions + const throwError = function(errorCode, errorMessage) { + if ((!errorCode && !errorMessage) || errorCode === PASSKEYS_REQUEST_CANCELED) { + // No error or canceled by user. Stop the timer but throw no exception. Fallback with be called instead. + return; + } + + if (errorCode === PASSKEYS_WAIT_FOR_LIFETIMER || errorCode === PASSKEYS_CREDENTIAL_IS_EXCLUDED) { + // Timer handled in the content script + return; + } + + if ([ PASSKEYS_DOMAIN_RPID_MISMATCH, PASSKEYS_DOMAIN_IS_NOT_VALID ].includes(errorCode)) { + throw new DOMException(errorMessage, DOMException.SECURITY_ERR); + } + + if (errorCode === PASSKEYS_NO_SUPPORTED_ALGORITHMS) { + throw new DOMException(errorMessage, DOMException.NOT_SUPPORTED_ERR); + } + + if ([ PASSKEYS_INVALID_CHALLENGE, PASSKEYS_INVALID_USER_ID ].includes(errorCode)) { + throw new TypeError(errorMessage); + } + + if ( + [ + PASSKEYS_ATTESTATION_NOT_SUPPORTED, + PASSKEYS_INVALID_URL_PROVIDED, + PASSKEYS_INVALID_USER_VERIFICATION, + PASSKEYS_EMPTY_PUBLIC_KEY, + PASSKEYS_UNKNOWN_ERROR, + PASSKEYS_ORIGIN_NOT_ALLOWED, + ].includes(errorCode) + ) { + throw new DOMException(errorMessage, 'NotAllowedError'); + } + + throw new DOMException(errorMessage, 'UnknownError'); + }; + const originalCredentials = navigator.credentials; const passkeysCredentials = { diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 4ca6ffd..6a4f5f8 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -74,11 +74,25 @@ "content/pwgen.js", "content/totp-autocomplete.js", "content/totp-field.js", - "content/username-field.js", - "content/passkeys-utils.js" + "content/username-field.js" ], "run_at": "document_idle", "all_frames": true + }, + { + "matches": [ + "" + ], + "exclude_matches": [ + "*://*/*.xml*", + "file:///*.xml*" + ], + "js": [ + "content/passkeys-inject.js", + "content/passkeys-utils.js" + ], + "run_at": "document_start", + "all_frames": true } ], "commands": {