Skip to content

Commit

Permalink
Passkey improvements (keepassxreboot#2121)
Browse files Browse the repository at this point in the history
Passkey improvements
  • Loading branch information
varjolintu authored Mar 4, 2024
1 parent 66d93f6 commit 87374df
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 82 deletions.
38 changes: 35 additions & 3 deletions keepassxc-browser/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
18 changes: 17 additions & 1 deletion keepassxc-browser/background/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down Expand Up @@ -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) {
Expand Down
83 changes: 61 additions & 22 deletions keepassxc-browser/content/keepassxc-browser.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -806,35 +810,70 @@ kpxc.enablePasskeys = function() {
passkeys.src = browser.runtime.getURL('content/passkeys.js');
document.documentElement.appendChild(passkeys);

document.addEventListener('kpxc-passkeys-request', async (ev) => {
if (ev.detail.action === 'passkeys_create') {
const publicKey = kpxcPasskeysUtils.buildCredentialCreationOptions(ev.detail.publicKey);
logDebug('publicKey', publicKey);
const startTimer = function(timeout) {
return setTimeout(() => {
throw new DOMException('lifetimeTimer has expired', 'NotAllowedError');
}, timeout);
};

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 stopTimer = function(lifetimeTimer) {
if (lifetimeTimer) {
clearTimeout(lifetimeTimer);
}
};

const responsePublicKey = kpxcPasskeysUtils.parsePublicKeyCredential(ret.response);
kpxcPasskeysUtils.sendPasskeysResponse(responsePublicKey);
}
} else if (ev.detail.action === 'passkeys_get') {
const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions(ev.detail.publicKey);
logDebug('publicKey', publicKey);
const letTimerRunOut = function (errorCode) {
return (
errorCode === PASSKEYS_WAIT_FOR_LIFETIMER ||
errorCode === PASSKEYS_CREDENTIAL_IS_EXCLUDED ||
errorCode === PASSKEYS_NO_LOGINS_FOUND
);
};

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

const responsePublicKey = kpxcPasskeysUtils.parseGetPublicKeyCredential(ret.response);
kpxcPasskeysUtils.sendPasskeysResponse(responsePublicKey);
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,
ev.detail.sameOriginWithAncestors,
);
logDebug('publicKey', publicKey);
await sendResponse('passkeys_register', publicKey, kpxcPasskeysUtils.parsePublicKeyCredential);
} else if (ev.detail.action === 'passkeys_get') {
const publicKey = kpxcPasskeysUtils.buildCredentialRequestOptions(
ev.detail.publicKey,
ev.detail.sameOriginWithAncestors,
);
logDebug('publicKey', publicKey);
await sendResponse('passkeys_get', publicKey, kpxcPasskeysUtils.parseGetPublicKeyCredential);
}
});
};
Expand Down
98 changes: 47 additions & 51 deletions keepassxc-browser/content/passkeys-utils.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,65 +20,61 @@ 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) {
throw new TypeError('challenge is shorter than required minimum length.');
}
};

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) {
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit 87374df

Please sign in to comment.