diff --git a/keepassxc-browser/content/credit-card-form.js b/keepassxc-browser/content/credit-card-form.js new file mode 100644 index 0000000..f040837 --- /dev/null +++ b/keepassxc-browser/content/credit-card-form.js @@ -0,0 +1,156 @@ +'use strict'; + +const cardNameRegex = /(cc|card|cardholder).*-name|name/i; +const cardNumberRegex = /(cc|card).*-(num|number|no)|number|card-no/i; +const cardTypeRegex = /(cc|card|cb).*-type|brand/i; +const cardCCVRegex = /(cc|card|security|verification).*-(code|cvv|cvc|csc)|cvv|cvc|csc/i; +const cardExpirationRegex = /(cc|card).*-(exp|expiry|mm-yy|mm-yyyy)|expiration-date/i; +const cardExpirationMonthRegex = /(cc-exp|card-exp|card-expiration|card-expire|expire|expiry).*-(month|mm|mo)/i; +const cardExpirationYearRegex = /(cc-exp|card-exp|card-expiration|card-expire|expire|expiry).*-(year|yr|yy|yyyy)/i; + +var kpxcCCIcons = {}; +kpxcCCIcons.icons = []; + +kpxcCCIcons.ccForm = { + ccName: undefined, + ccNumber: undefined, + ccExpMonth: undefined, + ccExpYear: undefined, + ccExp: undefined, + ccType: undefined, + ccCcv: undefined +}; + +kpxcCCIcons.newIcon = function(field, databaseState = DatabaseState.DISCONNECTED) { + kpxcCCIcons.icons.push(new CCFieldIcon(field, databaseState)); +}; + +kpxcCCIcons.switchIcon = function(state) { + kpxcCCIcons.icons.forEach(u => u.switchIcon(state)); +}; + +kpxcCCIcons.deleteHiddenIcons = function() { + kpxcUI.deleteHiddenIcons(kpxcCCIcons.icons, 'kpxc-totp-field'); +}; + +kpxcCCIcons.regexMatch = function(field, regex) { + const id = field.getLowerCaseAttribute('id'); + const name = field.getLowerCaseAttribute('name'); + const autocomplete = field.getLowerCaseAttribute('autocomplete'); + const placeholder = field.getLowerCaseAttribute('placeholder'); + + if ((id && id.match(regex)) + || (name && name.match(regex)) + || (autocomplete && autocomplete.match(regex)) + || (placeholder && placeholder.match(regex))) { + return true; + } + + return false; +}; + +kpxcCCIcons.detectCreditCardForm = function(field, allInputs) { + if (!field || !allInputs || allInputs.length === 0) { + return false; + } + + kpxcCCIcons.ccForm.ccName = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardNameRegex)); + kpxcCCIcons.ccForm.ccNumber = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardNumberRegex)); + kpxcCCIcons.ccForm.ccExpMonth = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardExpirationMonthRegex)); + kpxcCCIcons.ccForm.ccExpYear = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardExpirationYearRegex)); + kpxcCCIcons.ccForm.ccExp = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardExpirationRegex)); + kpxcCCIcons.ccForm.ccType = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardTypeRegex)); + kpxcCCIcons.ccForm.ccCcv = allInputs.find(i => kpxcCCIcons.regexMatch(i, cardCCVRegex)); + + if (!kpxcCCIcons.ccForm.ccName || !kpxcCCIcons.ccForm.ccCcv) { + return false; + } + + console.log(kpxcCCIcons.ccForm); + + return true; +}; + +// TODO: Adjust this function. Do we need a regex checks here? Those are also used the function above, +// so this function could just be a raw check. +kpxcCCIcons.isValid = function(field, forced) { + if (!forced) { + if (ignoredTypes.some(t => t === field.type) + || field.offsetWidth < MIN_INPUT_FIELD_OFFSET_WIDTH + || field.size < 2 + || ignoredTypes.some(t => t === field.autocomplete) + || field.readOnly + || field.inputMode === 'email' + || field.getAttribute('kpxc-totp-field') === 'true') { + return false; + } + } + + return true; +}; + +class CCFieldIcon extends Icon { + constructor(field, databaseState = DatabaseState.DISCONNECTED) { + super(field, databaseState); + + this.initField(field); + kpxcUI.monitorIconPosition(this); + } +} + +CCFieldIcon.prototype.initField = function(field) { + // Observer the visibility + if (this.observer) { + this.observer.observe(field); + } + + this.createIcon(field); + this.inputField = field; +}; + +CCFieldIcon.prototype.createIcon = function(field, segmented = false) { + const className = (isFirefox() ? 'moz' : 'default'); + + // Size the icon dynamically, but not greater than 24 or smaller than 14 + const size = Math.max(Math.min(24, field.offsetHeight - 4), 14); + const offset = kpxcUI.calculateIconOffset(field, size); + + const icon = kpxcUI.createElement('div', 'kpxc kpxc-cc-icon ' + className, + { + 'title': tr('ccFieldText'), + 'alt': tr('ccFieldIcon'), + 'size': size, + 'offset': offset + }); + icon.style.zIndex = '10000000'; + icon.style.width = Pixels(size); + icon.style.height = Pixels(size); + + if (this.databaseState === DatabaseState.DISCONNECTED || this.databaseState === DatabaseState.LOCKED) { + icon.style.filter = 'saturate(0%)'; + } + + icon.addEventListener('click', async function(e) { + if (!e.isTrusted) { + return; + } + + e.stopPropagation(); + await kpxc.receiveCredentialsIfNecessary(); + kpxcFill.fillFromCreditCardForm(field); + }); + + icon.addEventListener('mousedown', ev => ev.stopPropagation()); + icon.addEventListener('mouseup', ev => ev.stopPropagation()); + + kpxcUI.setIconPosition(icon, field, this.rtl, segmented); + this.icon = icon; + + const styleSheet = createStylesheet('css/credit-card.css'); + const wrapper = document.createElement('div'); + + this.shadowRoot = wrapper.attachShadow({ mode: 'closed' }); + this.shadowRoot.append(styleSheet); + this.shadowRoot.append(icon); + document.body.append(wrapper); +}; diff --git a/keepassxc-browser/content/fields.js b/keepassxc-browser/content/fields.js index d51b6d7..789f563 100644 --- a/keepassxc-browser/content/fields.js +++ b/keepassxc-browser/content/fields.js @@ -11,6 +11,7 @@ const kpxcFields = {}; kpxcFields.getAllCombinations = async function(inputs) { const combinations = []; let usernameField = null; + let ccField = undefined; for (const input of inputs) { if (!input) { @@ -38,11 +39,26 @@ kpxcFields.getAllCombinations = async function(inputs) { }; combinations.push(combination); + } else if (kpxcCCIcons.detectCreditCardForm(input, inputs)) { + ccField = input; } else { usernameField = input; } } + if (ccField) { + const combination = { + username: null, + password: null, + passwordInputs: [], + totp: null, + form: null, + ccInputs: kpxcCCIcons.ccForm + }; + + combinations.push(combination); + } + if (kpxc.singleInputEnabledForPage && combinations.length === 0 && usernameField) { const combination = { username: usernameField, diff --git a/keepassxc-browser/content/fill.js b/keepassxc-browser/content/fill.js index 2ab54e5..5cfa716 100644 --- a/keepassxc-browser/content/fill.js +++ b/keepassxc-browser/content/fill.js @@ -1,5 +1,14 @@ 'use strict'; +// TODO: Attribute names should be specified properly +const KPH_CC_CCV = 'KPH: CC_CCV'; +const KPH_CC_EXP = 'KPH: CC_EXP'; +const KPH_CC_EXP_MONTH = 'KPH: CC_EXP_MONTH'; +const KPH_CC_EXP_YEAR = 'KPH: CC_EXP_YEAR'; +const KPH_CC_NAME = 'KPH: CC_NAME'; +const KPH_CC_NUMBER = 'KPH: CC_NUMBER'; +const KPH_CC_TYPE = 'KPH: CC_TYPE'; + /** * @Object kpxcFill * The class for filling credentials. @@ -94,6 +103,32 @@ kpxcFill.fillFromAutofill = async function() { sendMessage('popup_login', [ { text: `${kpxc.credentials[0].login} (${kpxc.credentials[0].name})`, uuid: kpxc.credentials[0].uuid } ]); }; +// Fill requested from Credit Card form +kpxcFill.fillFromCreditCardForm = async function() { + if (kpxc.credentials.length === 0) { + logDebug('Error: Credential list is empty.'); + return; + } + + const fillCCValue = function(inputField, ccFieldName, fields) { + if (inputField) { + const newValue = fields.find(s => s[ccFieldName]); + if (newValue) { + kpxc.setValue(inputField, newValue[ccFieldName]); + } + } + }; + + const stringFields = kpxc.credentials[0].stringFields; + fillCCValue(kpxcCCIcons.ccForm.ccCcv, KPH_CC_CCV, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccExp, KPH_CC_EXP, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccExpMonth, KPH_CC_EXP_MONTH, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccExpYear, KPH_CC_EXP_YEAR, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccName, KPH_CC_NAME, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccNumber, KPH_CC_NUMBER, stringFields); + fillCCValue(kpxcCCIcons.ccForm.ccType, KPH_CC_TYPE, stringFields); +}; + // Fill requested by selecting credentials from the popup kpxcFill.fillFromPopup = async function(id, uuid) { if (!kpxc.credentials.length === 0 || !kpxc.credentials[id] || kpxc.combinations.length === 0) { diff --git a/keepassxc-browser/content/icons.js b/keepassxc-browser/content/icons.js index 99144b4..8724a91 100644 --- a/keepassxc-browser/content/icons.js +++ b/keepassxc-browser/content/icons.js @@ -6,11 +6,11 @@ */ const kpxcIcons = {}; kpxcIcons.icons = []; -kpxcIcons.iconTypes = { USERNAME: 0, PASSWORD: 1, TOTP: 2 }; +kpxcIcons.iconTypes = { USERNAME: 0, PASSWORD: 1, TOTP: 2, CC: 3 }; // Adds an icon to input field kpxcIcons.addIcon = async function(field, iconType) { - if (!field || iconType < 0 || iconType > 2) { + if (!field || iconType < kpxcIcons.iconTypes.USERNAME || iconType > kpxcIcons.iconTypes.CC) { return; } @@ -24,6 +24,9 @@ kpxcIcons.addIcon = async function(field, iconType) { } else if (iconType === kpxcIcons.iconTypes.TOTP && kpxcTOTPIcons.isValid(field)) { kpxcTOTPIcons.newIcon(field, kpxc.databaseState); iconSet = true; + } else if (iconType === kpxcIcons.iconTypes.CC && kpxcCCIcons.isValid(field)) { + kpxcCCIcons.newIcon(field, kpxc.databaseState); + iconSet = true; } if (iconSet) { @@ -69,10 +72,17 @@ kpxcIcons.addIconsFromForm = async function(form) { } }; + const addCCIcons = async function(c) { + if (c.ccInputs.ccName/* && kpxc.settings.showCCIcons*/) { + kpxcIcons.addIcon(c.ccInputs.ccName, kpxcIcons.iconTypes.CC); + } + }; + await Promise.all([ await addUsernameIcons(form), await addPasswordIcons(form), - await addTOTPIcons(form) + await addTOTPIcons(form), + await addCCIcons(form) ]); }; @@ -81,6 +91,7 @@ kpxcIcons.deleteHiddenIcons = function() { kpxcUsernameIcons.deleteHiddenIcons(); kpxcPasswordIcons.deleteHiddenIcons(); kpxcTOTPIcons.deleteHiddenIcons(); + kpxcCCIcons.deleteHiddenIcons(); }; // Initializes all icons needed to be shown @@ -112,4 +123,5 @@ kpxcIcons.switchIcons = function() { kpxcUsernameIcons.switchIcon(kpxc.databaseState); kpxcPasswordIcons.switchIcon(kpxc.databaseState); kpxcTOTPIcons.switchIcon(kpxc.databaseState); + kpxcCCIcons.switchIcon(kpxc.databaseState); }; diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index ef6ea1d..5d641dc 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -141,7 +141,7 @@ kpxc.getForm = function(inputField) { // Returns form action URL or document origin if it's not found kpxc.getFormActionUrl = function(combination) { - if (!combination || (combination.username === null && combination.password === null)) { + if (!combination || (combination.username === null && combination.password === null && !combination.ccInputs.ccCcv)) { return null; } diff --git a/keepassxc-browser/content/observer-helper.js b/keepassxc-browser/content/observer-helper.js index 08e3adc..cf36c27 100644 --- a/keepassxc-browser/content/observer-helper.js +++ b/keepassxc-browser/content/observer-helper.js @@ -137,13 +137,9 @@ kpxcObserverHelper.getInputs = function(target, ignoreVisibility = false) { return []; } - // Filter out any input fields with type 'hidden' right away const inputFields = []; - Array.from(target.getElementsByTagName('input')).forEach(e => { - if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { - inputFields.push(e); - } - }); + addInputsToList(target, 'input', inputFields); + addInputsToList(target, 'select', inputFields); if (target.nodeName === 'INPUT') { inputFields.push(target); @@ -270,3 +266,13 @@ kpxcObserverHelper.ignoredNode = function(target) { return false; }; + +// Appends inputs from target to the list with a selected tagName (input, select..) +const addInputsToList = function(target, tagName, inputFields) { + // Filter out any input fields with type 'hidden' right away + Array.from(target.getElementsByTagName(tagName)).forEach(e => { + if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { + inputFields.push(e); + } + }); +}; diff --git a/keepassxc-browser/css/credit-card.css b/keepassxc-browser/css/credit-card.css new file mode 100644 index 0000000..8822d0a --- /dev/null +++ b/keepassxc-browser/css/credit-card.css @@ -0,0 +1,14 @@ +.kpxc-cc-icon { + position: absolute; + cursor: pointer; +} + +.kpxc-cc-icon.default { + background: url('chrome-extension://__MSG_@@extension_id__/icons/credit-card.svg') right no-repeat; + background-size: contain; +} + +.kpxc-cc-icon.moz { + background: url('moz-extension://__MSG_@@extension_id__/icons/credit-card.svg') right no-repeat; + background-size: contain; +} diff --git a/keepassxc-browser/icons/credit-card.svg b/keepassxc-browser/icons/credit-card.svg new file mode 100644 index 0000000..6a9470e --- /dev/null +++ b/keepassxc-browser/icons/credit-card.svg @@ -0,0 +1,37 @@ + + + + + + diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 001c401..d456be4 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -58,6 +58,7 @@ "content/banner.js", "content/autocomplete.js", "content/credential-autocomplete.js", + "content/credit-card-form.js", "content/define.js", "content/fields.js", "content/fill.js", @@ -123,6 +124,7 @@ } }, "web_accessible_resources": [ + "icons/credit-card.svg", "icons/disconnected.svg", "icons/keepassxc.svg", "icons/key.svg", @@ -132,6 +134,7 @@ "css/banner.css", "css/button.css", "css/colors.css", + "css/credit-card.css", "css/define.css", "css/notification.css", "css/pwgen.css",