Inject passkeys scripts at document_start (#2432)

Load passkeys scripts at document_start
This commit is contained in:
Sami Vänttinen 2025-01-11 18:39:45 +02:00 committed by GitHub
parent 60355e8e1c
commit 78480bcf31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 298 additions and 234 deletions

View file

@ -33,7 +33,7 @@
"background": {
"service_worker": "background/background_service.js"
},
"content_scripts": [
"content_scripts": [
{
"matches": [
"<all_urls>"
@ -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": [
"<all_urls>"
],
"exclude_matches": [
"*://*/*.xml*",
"file:///*.xml*"
],
"js": [
"content/passkeys-inject.js",
"content/passkeys-utils.js"
],
"run_at": "document_start",
"all_frames": true
}
],
"commands": {

View file

@ -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": [
"<all_urls>"
],
"exclude_matches": [
"*://*/*.xml*",
"file:///*.xml*"
],
"js": [
"content/passkeys-inject.js",
"content/passkeys-utils.js"
],
"run_at": "document_start",
"all_frames": true
}
],
"commands": {

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [
"<all_urls>"
],
"exclude_matches": [
"*://*/*.xml*",
"file:///*.xml*"
],
"js": [
"content/passkeys-inject.js",
"content/passkeys-utils.js"
],
"run_at": "document_start",
"all_frames": true
}
],
"commands": {