Passkey improvements (#2121)

Passkey improvements
This commit is contained in:
Sami Vänttinen 2024-03-04 18:10:58 +02:00 committed by GitHub
parent 66d93f6911
commit 87374dfa89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 252 additions and 84 deletions

View file

@ -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.",

View file

@ -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) {

View file

@ -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);
}
});
};

View file

@ -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) {

View file

@ -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);
}