From 87374dfa89d11fb031a9fc31e5bd1b89237ffd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Mon, 4 Mar 2024 18:10:58 +0200 Subject: [PATCH] Passkey improvements (#2121) Passkey improvements --- keepassxc-browser/_locales/en/messages.json | 38 ++++++- keepassxc-browser/background/client.js | 18 +++- .../content/keepassxc-browser.js | 87 +++++++++++----- keepassxc-browser/content/passkeys-utils.js | 98 +++++++++---------- keepassxc-browser/content/passkeys.js | 95 +++++++++++++++++- 5 files changed, 252 insertions(+), 84 deletions(-) diff --git a/keepassxc-browser/_locales/en/messages.json b/keepassxc-browser/_locales/en/messages.json index 1901743..28b542d 100644 --- a/keepassxc-browser/_locales/en/messages.json +++ b/keepassxc-browser/_locales/en/messages.json @@ -147,9 +147,41 @@ "message": "Invalid URL provided.", "description": "Invalid URL provided." }, - "errorMessagePasskeysNonResidentKeysNotSupported": { - "message": "Non-Resident Keys are not supported.", - "description": "Non-Resident Keys are not supported." + "errorMessagePasskeysOriginNotAllowed": { + "message": "Origin is empty or not allowed.", + "description": "Origin is empty or not allowed." + }, + "errorMessagePasskeysDomainNotValid": { + "message": "Effective domain is not a valid domain.", + "description": "Effective domain is not a valid domain." + }, + "errorMessagePasskeysDomainRpIdMismatch": { + "message": "Origin and RP ID do not match.", + "description": "Origin and RP ID do not match." + }, + "errorMessagePasskeysNoSupportedAlgorithms": { + "message": "No supported algorithms were provided.", + "description": "No supported algorithms were provided." + }, + "errorMessagePasskeysWaitforLifeTimer": { + "message": "Wait for timer to expire.", + "description": "Wait for timer to expire." + }, + "errorMessagePasskeysUnknownError": { + "message": "Unknown Passkeys error.", + "description": "Unknown Passkeys error." + }, + "errorMessagePasskeysInvalidChallenge": { + "message": "Challenge is shorter than required minimum length.", + "description": "Challenge is shorter than required minimum length." + }, + "errorMessagePasskeysInvalidUserId": { + "message": "user.id does not match the required length.", + "description": "user.id does not match the required length." + }, + "errorMessagePasskeysContextIsNotSecure": { + "message": "Context is not secure.", + "description": "Context is not secure." }, "errorNotConnected": { "message": "Not connected to KeePassXC.", diff --git a/keepassxc-browser/background/client.js b/keepassxc-browser/background/client.js index 7c194c0..c98b01d 100644 --- a/keepassxc-browser/background/client.js +++ b/keepassxc-browser/background/client.js @@ -26,12 +26,21 @@ const kpErrors = { NO_GROUPS_FOUND: 16, CANNOT_CREATE_NEW_GROUP: 17, NO_VALID_UUID_PROVIDED: 18, + ACCESS_TO_ALL_ENTRIES_DENIED: 19, PASSKEYS_ATTESTATION_NOT_SUPPORTED: 20, PASSKEYS_CREDENTIAL_IS_EXCLUDED: 21, PASSKEYS_REQUEST_CANCELED: 22, PASSKEYS_INVALID_USER_VERIFICATION: 23, PASSKEYS_EMPTY_PUBLIC_KEY: 24, PASSKEYS_INVALID_URL_PROVIDED: 25, + PASSKEYS_ORIGIN_NOT_ALLOWED: 26, + PASSKEYS_DOMAIN_IS_NOT_VALID: 27, + PASSKEYS_DOMAIN_RPID_MISMATCH: 28, + PASSKEYS_NO_SUPPORTED_ALGORITHMS: 29, + PASSKEYS_WAIT_FOR_LIFETIMER: 30, + PASSKEYS_UNKNOWN_ERROR: 31, + PASSKEYS_INVALID_CHALLENGE: 32, + PASSKEYS_INVALID_USER_ID: 33, errorMessages: { 0: { msg: tr('errorMessageUnknown') }, @@ -60,7 +69,14 @@ const kpErrors = { 23: { msg: tr('errorMessagePasskeysInvalidUserVerification') }, 24: { msg: tr('errorMessagePasskeysEmptyPublicKey') }, 25: { msg: tr('errorMessagePasskeysInvalidUrlProvided') }, - 26: { msg: tr('errorMessagePasskeysNonResidentKeysNotSupported') }, + 26: { msg: tr('errorMessagePasskeysOriginNotAllowed') }, + 27: { msg: tr('errorMessagePasskeysDomainNotValid') }, + 28: { msg: tr('errorMessagePasskeysDomainRpIdMismatch') }, + 29: { msg: tr('errorMessagePasskeysNoSupportedAlgorithms') }, + 30: { msg: tr('errorMessagePasskeysWaitforLifeTimer') }, + 31: { msg: tr('errorMessagePasskeysUnknownError') }, + 32: { msg: tr('errorMessagePasskeysInvalidChallenge') }, + 33: { msg: tr('errorMessagePasskeysInvalidUserId') }, }, getError(errorCode) { diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index d885b80..a267996 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -1,5 +1,9 @@ 'use strict'; +const PASSKEYS_NO_LOGINS_FOUND = 15; +const PASSKEYS_WAIT_FOR_LIFETIMER = 21; +const PASSKEYS_CREDENTIAL_IS_EXCLUDED = 30; + // Contains already called method names const _called = {}; _called.automaticRedetectCompleted = false; @@ -806,35 +810,70 @@ kpxc.enablePasskeys = function() { 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); + // Do not create a notification for this error + if (ret?.response?.errorCode !== PASSKEYS_WAIT_FOR_LIFETIMER) { + kpxcUI.createNotification('error', errorMessage); + } + + if (letTimerRunOut(ret?.response?.errorCode)) { + return; + } + } + + const responsePublicKey = callback(ret.response); + kpxcPasskeysUtils.sendPasskeysResponse(responsePublicKey, 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); + const publicKey = kpxcPasskeysUtils.buildCredentialCreationOptions( + ev.detail.publicKey, + ev.detail.sameOriginWithAncestors, + ); logDebug('publicKey', publicKey); - - const ret = await sendMessage('passkeys_register', [ publicKey, window.location.origin ]); - if (ret) { - if (ret.response && ret.response.errorCode) { - const errorMessage = await sendMessage('get_error_message', ret.response.errorCode); - kpxcUI.createNotification('error', errorMessage); - } - - const responsePublicKey = kpxcPasskeysUtils.parsePublicKeyCredential(ret.response); - kpxcPasskeysUtils.sendPasskeysResponse(responsePublicKey); - } + await sendResponse('passkeys_register', publicKey, kpxcPasskeysUtils.parsePublicKeyCredential); } else if (ev.detail.action === 'passkeys_get') { - const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions(ev.detail.publicKey); + const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions( + ev.detail.publicKey, + ev.detail.sameOriginWithAncestors, + ); logDebug('publicKey', publicKey); - - const ret = await sendMessage('passkeys_get', [ publicKey, window.location.origin ]); - if (ret) { - if (ret.response && ret.response.errorCode) { - const errorMessage = await sendMessage('get_error_message', ret.response.errorCode); - kpxcUI.createNotification('error', errorMessage); - } - - const responsePublicKey = kpxcPasskeysUtils.parseGetPublicKeyCredential(ret.response); - kpxcPasskeysUtils.sendPasskeysResponse(responsePublicKey); - } + await sendResponse('passkeys_get', publicKey, kpxcPasskeysUtils.parseGetPublicKeyCredential); } }); }; diff --git a/keepassxc-browser/content/passkeys-utils.js b/keepassxc-browser/content/passkeys-utils.js index cc98e7b..1ee553a 100644 --- a/keepassxc-browser/content/passkeys-utils.js +++ b/keepassxc-browser/content/passkeys-utils.js @@ -1,5 +1,9 @@ 'use strict'; +const MINIMUM_TIMEOUT = 15000; +const DEFAULT_TIMEOUT = 30000; +const DISCOURAGED_TIMEOUT = 120000; + const stringToArrayBuffer = function(str) { const arr = Uint8Array.from(str, c => c.charCodeAt(0)); return arr.buffer; @@ -16,10 +20,17 @@ const arrayBufferToBase64 = function(buf) { return window.btoa(str).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); }; -// Error checks for both registration and authentication -const checkErrors = function(pkOptions) { - if (pkOptions.sameOriginWithAncestors !== undefined && pkOptions.sameOriginWithAncestors === false) { - throw new DOMException('Cross-origin register is not allowed.', DOMException.NotAllowedError); +const checkErrors = function(pkOptions, sameOriginWithAncestors) { + if (!pkOptions) { + throw new Error('No publicKey configuration options were provided'); + } + + if (pkOptions.signal && pkOptions.signal.aborted) { + throw new DOMException('Abort signalled', DOMException.AbortError); + } + + if (!sameOriginWithAncestors) { + throw new DOMException('Cross-origin register or authentication is not allowed.', DOMException.NotAllowedError); } if (pkOptions.challenge.length < 16) { @@ -27,54 +38,43 @@ const checkErrors = function(pkOptions) { } }; +const getTimeout = function(userVerification, timeout) { + if (!timeout || Number(timeout) === 0 || isNaN(Number(timeout))) { + return userVerification === 'discouraged' ? DISCOURAGED_TIMEOUT : DEFAULT_TIMEOUT; + } + + // Note: A suggested reasonable range for the timeout member of options is 15 seconds to 120 seconds. + if (Number(timeout) < MINIMUM_TIMEOUT || Number(timeout) > DISCOURAGED_TIMEOUT) { + return DEFAULT_TIMEOUT; + } + + return Number(timeout); +}; + const kpxcPasskeysUtils = {}; // Sends response from KeePassXC back to the injected script -kpxcPasskeysUtils.sendPasskeysResponse = function(publicKey) { - const response = { publicKey: publicKey, fallback: kpxc.settings.passkeysFallback }; +kpxcPasskeysUtils.sendPasskeysResponse = function(publicKey, errorCode, errorMessage) { + const response = errorCode + ? { errorCode: errorCode, errorMessage: errorMessage } + : { publicKey: publicKey, fallback: kpxc.settings.passkeysFallback }; const details = isFirefox() ? cloneInto(response, document.defaultView) : response; document.dispatchEvent(new CustomEvent('kpxc-passkeys-response', { detail: details })); }; // Create a new object with base64 strings for KeePassXC -kpxcPasskeysUtils.buildCredentialCreationOptions = function(pkOptions) { +kpxcPasskeysUtils.buildCredentialCreationOptions = function(pkOptions, sameOriginWithAncestors) { try { - checkErrors(pkOptions); - - if (pkOptions.user.id && (pkOptions.user.id.length < 1 || pkOptions.user.id.length > 64)) { - throw new TypeError('user.id does not match the required length.'); - } - - if (!pkOptions.rp.id) { - pkOptions.rp.id = window.location.hostname; - pkOptions.rp.name = window.location.hostname; - } else if (!window.location.hostname.endsWith(pkOptions.rp.id)) { - throw new DOMException('Site domain differs from RP ID', DOMException.SecurityError); - } - - if (!pkOptions.pubKeyCredParams || pkOptions.pubKeyCredParams.length === 0) { - pkOptions.pubKeyCredParams.push({ - 'type': 'public-key', - 'alg': -7 - }); - pkOptions.pubKeyCredParams.push({ - 'type': 'public-key', - 'alg': -257 - }); - } + checkErrors(pkOptions, sameOriginWithAncestors); const publicKey = {}; - publicKey.attestation = pkOptions.attestation || 'none'; - publicKey.authenticatorSelection = pkOptions.authenticatorSelection || { userVerification: 'preferred' }; - if (!publicKey.authenticatorSelection.userVerification) { - publicKey.authenticatorSelection.userVerification = 'preferred'; - } - + publicKey.attestation = pkOptions?.attestation; + publicKey.authenticatorSelection = pkOptions?.authenticatorSelection; publicKey.challenge = arrayBufferToBase64(pkOptions.challenge); - publicKey.extensions = pkOptions.extensions; - publicKey.pubKeyCredParams = pkOptions.pubKeyCredParams; - publicKey.rp = pkOptions.rp; - publicKey.timeout = pkOptions.timeout; + publicKey.extensions = pkOptions?.extensions; + publicKey.pubKeyCredParams = pkOptions?.pubKeyCredParams; + publicKey.rp = pkOptions?.rp; + publicKey.timeout = getTimeout(publicKey?.authenticatorSelection?.userVerification, pkOptions?.timeout); publicKey.excludeCredentials = []; if (pkOptions.excludeCredentials && pkOptions.excludeCredentials.length > 0) { @@ -101,21 +101,17 @@ kpxcPasskeysUtils.buildCredentialCreationOptions = function(pkOptions) { }; // Create a new object with base64 strings for KeePassXC -kpxcPasskeysUtils.buildCredentialRequestOptions = function(pkOptions) { +kpxcPasskeysUtils.buildCredentialRequestOptions = function(pkOptions, sameOriginWithAncestors) { try { - checkErrors(pkOptions); - - if (!pkOptions.rpId) { - pkOptions.rpId = window.location.hostname; - } else if (!window.location.hostname.endsWith(pkOptions.rpId)) { - throw new DOMException('Site domain differs from RP ID', DOMException.SecurityError); - } + checkErrors(pkOptions, sameOriginWithAncestors); const publicKey = {}; publicKey.challenge = arrayBufferToBase64(pkOptions.challenge); - publicKey.rpId = pkOptions.rpId; - publicKey.timeout = pkOptions.timeout; - publicKey.userVerification = pkOptions.userVerification || 'preferred'; + publicKey.enterpriseAttestationPossible = false; + publicKey.extensions = pkOptions?.extensions; + publicKey.rpId = pkOptions?.rpId; + publicKey.timeout = getTimeout(publicKey?.userVerification, pkOptions?.timeout); + publicKey.userVerification = pkOptions?.userVerification; publicKey.allowCredentials = []; if (pkOptions.allowCredentials && pkOptions.allowCredentials.length > 0) { diff --git a/keepassxc-browser/content/passkeys.js b/keepassxc-browser/content/passkeys.js index 2aa6fb5..034097a 100644 --- a/keepassxc-browser/content/passkeys.js +++ b/keepassxc-browser/content/passkeys.js @@ -1,5 +1,20 @@ '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; + // Posts a message to extension's content script and waits for response const postMessageToExtension = function(request) { return new Promise((resolve, reject) => { @@ -22,6 +37,55 @@ const postMessageToExtension = function(request) { }); }; +const isSameOriginWithAncestors = function() { + try { + return window.self.origin === window.top.origin; + } catch (err) { + return false; + } +}; + +// Throws errors to a correct exceptions +const handleError = 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 originalCredentials = navigator.credentials; @@ -31,26 +95,41 @@ const postMessageToExtension = function(request) { return null; } - const response = await postMessageToExtension({ action: 'passkeys_create', publicKey: options.publicKey }); + const sameOriginWithAncestors = isSameOriginWithAncestors(); + const response = await postMessageToExtension({ + action: 'passkeys_create', + publicKey: options.publicKey, + sameOriginWithAncestors: sameOriginWithAncestors, + }); + if (!response.publicKey) { + handleError(response?.errorCode, response?.errorMessage); return response.fallback ? originalCredentials.create(options) : null; } response.publicKey.getClientExtensionResults = () => {}; response.publicKey.clientExtensionResults = () => {}; + response.publicKey.response.getTransports = () => []; return response.publicKey; }, async get(options) { - if (!options.publicKey) { + if (!options.publicKey || options?.mediation === 'silent') { return null; } - if (options.mediation === 'conditional') { + if (options?.mediation === 'conditional') { return originalCredentials.get(options); } - const response = await postMessageToExtension({ action: 'passkeys_get', publicKey: options.publicKey }); + const sameOriginWithAncestors = isSameOriginWithAncestors(); + const response = await postMessageToExtension({ + action: 'passkeys_get', + publicKey: options.publicKey, + sameOriginWithAncestors: sameOriginWithAncestors, + }); + if (!response.publicKey) { + handleError(response?.errorCode, response?.errorMessage); return response.fallback ? originalCredentials.get(options) : null; } @@ -61,13 +140,19 @@ const postMessageToExtension = function(request) { }; const isConditionalMediationAvailable = async() => false; + const isUserVerifyingPlatformAuthenticatorAvailable = async() => true; // Overwrite navigator.credentials and PublicKeyCredential.isConditionalMediationAvailable. // The latter requires user to select which device to use for authentication, but for now browsers cannot // select a software authenticator. This could be removed in the future. try { Object.defineProperty(navigator, 'credentials', { value: passkeysCredentials }); - Object.defineProperty(window.PublicKeyCredential, 'isConditionalMediationAvailable', { value: isConditionalMediationAvailable }); + Object.defineProperty(window.PublicKeyCredential, 'isConditionalMediationAvailable', { + value: isConditionalMediationAvailable, + }); + Object.defineProperty(window.PublicKeyCredential, 'isUserVerifyingPlatformAuthenticatorAvailable', { + value: isUserVerifyingPlatformAuthenticatorAvailable, + }); } catch (err) { console.log('Cannot override navigator.credentials: ', err); }