commit e227f9aaf7938868087eb475fa1b0cccf8e16473 Author: Evert Prants Date: Sun Aug 28 10:00:51 2022 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..d1f47cf --- /dev/null +++ b/extension/background.js @@ -0,0 +1,169 @@ +const tabForms = {}; +const tabMatches = {}; + +let lastTabId = null; +let currentPageForms = null; +let currentPageMatches = null; +let addedContexts = []; + +function askNative(msg) { + const port = chrome.runtime.connectNative('ee.lunasqu.password_manager'); + + return new Promise((resolve, reject) => { + let gotResponse = false; + port.onMessage.addListener((msg) => { + gotResponse = true; + resolve(msg); + port.disconnect(); + }); + + port.onDisconnect.addListener((msg) => { + if (!gotResponse) { + gotResponse = true; + reject(msg); + } + }); + + port.postMessage(msg); + }); +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'fillContextMenu', + title: 'Autofill password', + contexts: ['editable'], + }); +}); + +function addContextMenuItems() { + if (!currentPageMatches) { + return; + } + + clearContextMenu(); + for (const match of currentPageMatches) { + const ctxID = `fill-${match}`; + chrome.contextMenus.create({ + id: ctxID, + title: match, + contexts: ['editable'], + parentId: 'fillContextMenu', + }); + addedContexts.push(ctxID); + } +} + +function clearContextMenu() { + addedContexts.forEach((id) => { + try { + chrome.contextMenus.remove(id); + } catch (e) { + console.error(e); + } + }); + addedContexts.length = 0; +} + +async function injectForeground() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab || !tab.url) { + return; + } + + lastTabId = tab.id; + + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['./foreground.js'], + }); + + const parsed = new URL(tab.url); + + askNative({ + domain: parsed.host, + }).then(({ results }) => { + if (results && results.length) { + currentPageMatches = results; + tabMatches[tab.id] = results; + chrome.tabs.sendMessage(tab.id, { + message: 'has_entries', + payload: results, + }); + addContextMenuItems(); + } + }); +} + +async function sendPasswordToTab(rdata, existingTab) { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const realTab = existingTab || tab; + + if (!realTab || !realTab.url) { + return; + } + + chrome.tabs.sendMessage(realTab.id, { + message: 'fill_password', + payload: rdata, + }); +} + +chrome.webNavigation.onCompleted.addListener((ee) => { + if (ee.frameType !== 'outermost_frame') { + return; + } + currentPageForms = null; + currentPageMatches = null; + injectForeground(); +}); + +chrome.tabs.onActivated.addListener(function (activeInfo) { + lastTabId = activeInfo.tabId; + currentPageForms = tabForms[activeInfo.tabId]; + currentPageMatches = tabMatches[activeInfo.tabId]; + + clearContextMenu(); + if (currentPageMatches) { + addContextMenuItems(); + } +}); + +chrome.tabs.onRemoved.addListener(function (tabId, info) { + delete tabForms[tabId]; + delete tabMatches[tabId]; +}); + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.message === 'login_forms') { + if (sender && sender.tab && sender.tab.id) { + tabForms[sender.tab.id] = request.payload; + } + + if (sender.tab.id === lastTabId) { + currentPageForms = request.payload; + } + sendResponse(true); + } + + if (request.message === 'autofill') { + askNative({ + getPassword: request.payload, + }).then((response) => { + sendPasswordToTab(response); + }); + sendResponse(true); + } +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info && info.menuItemId && info.menuItemId.startsWith('fill-')) { + const fillId = info.menuItemId.substring(5); + askNative({ + getPassword: fillId, + }).then((response) => { + sendPasswordToTab(response, tab); + }); + } +}); diff --git a/extension/foreground.js b/extension/foreground.js new file mode 100644 index 0000000..388924a --- /dev/null +++ b/extension/foreground.js @@ -0,0 +1,229 @@ +(() => { + let currentPageMatches = null; + let currentForm = null; + let successfulAttachment = false; + let focusedField = null; + + function generateQuerySelector(el) { + if (el.tagName.toLowerCase() === 'html') { + return 'HTML'; + } + + let str = el.tagName; + str += el.id != '' ? '#' + el.id : ''; + if (el.className) { + const classes = el.className.split(/\s/); + for (let i = 0; i < classes.length; i++) { + str += '.' + classes[i]; + } + } + + return generateQuerySelector(el.parentNode) + ' > ' + str; + } + + function lookForLoginForms() { + const allPasswordInputs = document.querySelectorAll( + 'input[type="password"]' + ); + let loginForms = []; + + allPasswordInputs.forEach((field) => { + const closestForm = field.closest('form'); + if (!closestForm) { + return; + } + + const existing = loginForms.find(({ form }) => form === closestForm); + if (existing) { + existing.passwordAgain = field; + return; + } + + let usernameField; + const allInputs = Array.from(closestForm.querySelectorAll('input')); + const contenders = allInputs.filter((element) => { + const nameAttr = (element.getAttribute('name') || '').toLowerCase(); + const idAttr = (element.getAttribute('id') || '').toLowerCase(); + const closestLabel = closestForm.querySelector( + `label[for="${idAttr}"]` + ); + + if (closestLabel) { + const labelText = closestLabel.innerText.toLowerCase().trim(); + if ( + labelText.startsWith('user') || + labelText.startsWith('email') || + labelText.startsWith('e-mail') + ) { + return true; + } + } + + if (nameAttr.includes('username')) { + return true; + } + + if (nameAttr.includes('email')) { + return true; + } + + if (nameAttr.includes('name')) { + return true; + } + }); + + usernameField = contenders[0]; + + loginForms.push({ + password: field, + username: usernameField, + form: closestForm, + }); + }); + + if (loginForms.length) { + chrome.runtime.sendMessage({ + message: 'login_forms', + payload: loginForms.map((stringify) => ({ + form: generateQuerySelector(stringify.form), + password: generateQuerySelector(stringify.password), + passwordAgain: stringify.passwordAgain + ? generateQuerySelector(stringify.passwordAgain) + : null, + username: stringify.username + ? generateQuerySelector(stringify.username) + : null, + })), + }); + } + + return loginForms; + } + + function createOptionsSelect(commitAutofill) { + const select = document.createElement('select'); + const unopt = document.createElement('option'); + unopt.innerText = 'Select autofill...'; + select.appendChild(unopt); + + Object.assign(select.style, { + pointerEvents: 'all', + }); + + currentPageMatches.forEach((match) => { + const option = document.createElement('option'); + option.value = match; + option.innerText = match; + select.appendChild(option); + }); + + select.addEventListener('change', function () { + const val = select.value; + if (val) { + commitAutofill(val); + } + }); + return select; + } + + function attachLoginFormHighlight(info) { + successfulAttachment = true; + const autoFillContainer = document.createElement('div'); + Object.assign(autoFillContainer.style, { + position: 'absolute', + outline: '10px solid rgb(0 170 255 / 60%)', + boxSizing: 'border-box', + pointerEvents: 'none', + borderRadius: '10px', + zIndex: '10000000', + }); + document.body.appendChild(autoFillContainer); + + function reposition() { + const boundingBox = info.form.getBoundingClientRect(); + Object.assign(autoFillContainer.style, { + top: `${boundingBox.y - 10 + window.scrollY}px`, + left: `${boundingBox.x - 10}px`, + width: `${boundingBox.width + 20}px`, + height: `${boundingBox.height + 20}px`, + }); + } + + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition); + reposition(); + + const select = createOptionsSelect((password) => { + currentForm = info; + chrome.runtime.sendMessage({ + message: 'autofill', + payload: password, + }); + }); + autoFillContainer.appendChild(select); + } + + function init() { + const forms = lookForLoginForms(); + if (forms.length) { + forms.forEach((form) => attachLoginFormHighlight(form)); + } + } + + function fakeTriggers(input, value) { + const inputEvt = new Event('input'); + const changeEvt = new Event('change'); + input.focus(); + input.value = value; + input.setAttribute('value', value); + input.dispatchEvent(inputEvt); + input.dispatchEvent(changeEvt); + input.blur(); + } + + chrome.runtime.onMessage.addListener(function ( + request, + sender, + sendResponse + ) { + if (request.message === 'has_entries') { + currentPageMatches = request.payload; + init(); + sendResponse(true); + } + + if (request.message === 'fill_password') { + if (request && request.payload.password) { + if (currentForm) { + fakeTriggers(currentForm.password, request.payload.password); + + if (currentForm.username && request.payload.username) { + fakeTriggers(currentForm.username, request.payload.username); + } + + setTimeout(() => currentForm.password.focus(), 100); + } else if (focusedField) { + fakeTriggers(focusedField, request.payload.password); + } + } + + sendResponse(true); + } + }); + + document.addEventListener('click', function (e) { + if (!currentPageMatches) { + return; + } + + focusedField = e.target; + + if (successfulAttachment) { + return; + } + + if (e.target && e.target.tagName === 'INPUT') { + init(); + } + }); +})(); diff --git a/extension/icons/icon.png b/extension/icons/icon.png new file mode 100644 index 0000000..d0b4ffe Binary files /dev/null and b/extension/icons/icon.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..6a984b0 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Password manager", + "description": "Password manager", + "version": "1.0", + "manifest_version": 3, + "icons": { + "48": "icons/icon.png" + }, + + "host_permissions": ["https://*/"], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "permissions": [ + "scripting", + "contextMenus", + "activeTab", + "webNavigation", + "nativeMessaging" + ], + + "action": { + "default_popup": "popup.html" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..ab7ea2a --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,13 @@ + + + + + + + Popup + + +

test

+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..c4e85a2 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1 @@ +// chrome.tabs.sendMessage(tabId, "message", function (response)); diff --git a/host/ee.lunasqu.password_manager.json b/host/ee.lunasqu.password_manager.json new file mode 100644 index 0000000..97d4353 --- /dev/null +++ b/host/ee.lunasqu.password_manager.json @@ -0,0 +1,7 @@ +{ + "name": "ee.lunasqu.password_manager", + "description": "Password manager native messager", + "path": "server.js", + "type": "stdio", + "allowed_origins": [ "chrome-extension://hafjmpnminafbnikdojlbafngmeoplik/" ] +} diff --git a/host/package-lock.json b/host/package-lock.json new file mode 100644 index 0000000..f92310d --- /dev/null +++ b/host/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "password-store-extension", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "chrome-native-messaging": "^0.2.0" + } + }, + "node_modules/chrome-native-messaging": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chrome-native-messaging/-/chrome-native-messaging-0.2.0.tgz", + "integrity": "sha512-RgTFMY0x/K5JUSLfd+rTaWxNvkliYqNR41h5DbbkjlDCbxmZSXIHAYOSTbIazjyorzpK26Y2H9BXnboy4s9SXw==" + } + }, + "dependencies": { + "chrome-native-messaging": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chrome-native-messaging/-/chrome-native-messaging-0.2.0.tgz", + "integrity": "sha512-RgTFMY0x/K5JUSLfd+rTaWxNvkliYqNR41h5DbbkjlDCbxmZSXIHAYOSTbIazjyorzpK26Y2H9BXnboy4s9SXw==" + } + } +} diff --git a/host/package.json b/host/package.json new file mode 100644 index 0000000..b08421e --- /dev/null +++ b/host/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "chrome-native-messaging": "^0.2.0" + } +} diff --git a/host/server.js b/host/server.js new file mode 100755 index 0000000..0313e16 --- /dev/null +++ b/host/server.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +const nativeMessage = require('chrome-native-messaging'); +const childProcess = require('child_process'); +const fs = require('fs/promises'); +const path = require('path'); + +const passwds = path.join(process.env.HOME, '.password-store'); + +async function recurseDir(dir) { + const array = []; + const paths = await fs.readdir(dir); + const root = path.basename(dir); + for (const file of paths) { + const stat = await fs.stat(path.join(dir, file)); + if (file.startsWith('.')) { + continue; + } + + if (stat.isDirectory()) { + const children = await recurseDir(path.join(dir, file)); + array.push(...children); + } else { + array.push(path.join(dir, file.replace('.gpg', ''))); + } + } + return array; +} + +async function findSiteInPasswords(siteDomain) { + const allPasswords = await recurseDir(passwds); + let sdwTLD = siteDomain.split('.'); + sdwTLD = sdwTLD.slice(sdwTLD.length - 2, sdwTLD.length - 1).join('.'); + + return allPasswords + .map((file) => file.substring(passwds.length + 1)) + .filter((item) => { + const name = item.toLowerCase(); + return name.includes(siteDomain) || (sdwTLD && name.includes(sdwTLD)); + }); +} + +async function getPassword(password) { + return new Promise((resolve, reject) => { + childProcess.exec(`pass ${password}`, (error, stdout, stderr) => { + if (stderr && stderr.length) { + return resolve(null); + } + + resolve(stdout.toString().split('\n')); + }); + }); +} + +process.stdin + .pipe(new nativeMessage.Input()) + .pipe( + new nativeMessage.Transform(function (msg, push, done) { + if (msg.domain) { + findSiteInPasswords(msg.domain).then((results) => { + push({ results }); + done(); + }); + return; + } + + if (msg.getPassword) { + getPassword(msg.getPassword).then(([password, username]) => { + push({ password, username }); + done(); + }); + return; + } + + push({ hello: true }); + done(); + }) + ) + .pipe(new nativeMessage.Output()) + .pipe(process.stdout);