diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ff2dfbc --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "env": { + "webextensions": true, + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended" + ], + "globals": { + "process": "readonly" + }, + "ignorePatterns": ["node_modules"], + "parserOptions": { + "ecmaVersion": "latest", + "ecmaFeatures": { + "jsx": true + }, + "sourceType": "module" + }, + "plugins": ["@stylistic/js"], + "rules": { + "no-console": ["error", {"allow": ["warn", "error"]}], + "no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], + "@stylistic/js/indent": ["error", "tab"], + "@stylistic/js/semi": ["error", "always"] + } +} diff --git a/.gitignore b/.gitignore index 8234428..645de9c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.zip *.png -.web-extension-id \ No newline at end of file +.web-extension-id + +node_modules \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7715576 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock = false \ No newline at end of file diff --git a/background.js b/background.js index ff82fbc..8515be2 100644 --- a/background.js +++ b/background.js @@ -4,54 +4,75 @@ let state = { salt: window.crypto.getRandomValues(new Uint8Array(16)), key: null, entries: [] +}; + +let popup_port = null; // communication channel with popup + +function send_state(state) +{ + popup_port.postMessage({type: 'state', data: state}); } -let popup_port = null // communication channel with popup +function show_error(error) +{ + console.error(error); + popup_port.postMessage({type: 'notification', data: error}); +} function b32decode(text) { - const up_text = text.toUpperCase() - let result = [] - let lshift = 3 - let carry = 0 + const up_text = text.toUpperCase(); + let result = []; + let lshift = 3; + let carry = 0; for(const index in up_text) { - const char = up_text[index] + const char = up_text[index]; if(char === '=') - break - let value = char.charCodeAt() - 65 - if(value < 0) - value += 41 - if(value < 0 || value > 31) - throw `Incorrect Base32 char '${text[index]}' at index ${index}` + break; + let value = char.charCodeAt(); + if(value >= 65 && value <= 90) // char in [A- Z] + value -= 65; + else if(value >= 50 && value <= 55) // char in [2-7] + value -= 24; + else + throw `Incorrect Base32 char '${text[index]}' at index ${index}`; if(lshift > 0) // next value is needed to complete the octet { - carry += value << lshift - lshift -= 5 + carry += value << lshift; + lshift -= 5; if(lshift === 0) // last value aligned with octet - carry = 0 + carry = 0; } else // getting the last octet' bits { - result.push(carry + (value >>> (-lshift))) - carry = (value << (lshift + 8)) & 248 // if lshit is 0 this will always be 0 - lshift += 3 + result.push(carry + (value >>> (-lshift))); + carry = (value << (lshift + 8)) & 248; // if lshit is 0 this will always be 0 + lshift += 3; } } if(carry !== 0) - result.push(carry) - return result + result.push(carry); + return result; } // Create encryption key from password async function setKey(password) { - const storage = await browser.storage.local.get() + const storage = await browser.storage.local.get(); if(storage.entries !== undefined && storage.iv !== undefined && storage.salt !== undefined) { - state.iv = new Uint8Array(b32decode(storage.iv)) - state.salt = new Uint8Array(b32decode(storage.salt)) + try + { + state.iv = new Uint8Array(b32decode(storage.iv)); + state.salt = new Uint8Array(b32decode(storage.salt)); + } + catch(error) + { + show_error(error); + return; + } } let encoder = new TextEncoder(); @@ -60,7 +81,7 @@ async function setKey(password) encoder.encode(password), 'PBKDF2', false, - ['deriveKey']) + ['deriveKey']); state.key = await window.crypto.subtle.deriveKey( { name: "PBKDF2", @@ -71,17 +92,28 @@ async function setKey(password) key_material, { "name": "AES-GCM", "length": 256}, true, - ["encrypt", "decrypt"]) + ["encrypt", "decrypt"]); // If entries are set in the state we automatically restore them if(storage.entries !== undefined) - restore(storage) + restore(storage); else - popup_port.postMessage(state) + send_state(state); } // Decrypt entries saved in local storage async function restore(storage) { + let decoded_secret = null; + try + { + decoded_secret = new Uint8Array(b32decode(storage.entries)); + } + catch(error) + { + show_error(error); + return; + } + try { let decrypted = await window.crypto.subtle.decrypt( @@ -89,42 +121,40 @@ async function restore(storage) name: "AES-GCM", iv: state.iv }, - state.key, - new Uint8Array(b32decode(storage.entries)) + state.key, decoded_secret ); let decoder = new TextDecoder(); - state.entries = JSON.parse(decoder.decode(decrypted)) - popup_port.postMessage(state) + state.entries = JSON.parse(decoder.decode(decrypted)); + send_state(state); } - catch (error) + catch(_error) { - console.error('Cannot decrypt entries: wrong password') + state.key = null; + show_error('Cannot decrypt entries: wrong password'); } } function connected(port) { - popup_port = port + popup_port = port; popup_port.onMessage.addListener((message) => { - if(message.command === 'init') + if(message.command === 'init') { if(state.key === null) - popup_port.postMessage(state) + send_state(state); else - browser.storage.local.get().then((storage) => { - restore(storage) - }) + browser.storage.local.get().then((storage) => { restore(storage); }); } else if(message.command === 'key') - setKey(message.password) + setKey(message.password); else if(message.command === 'logout') { - state.key = null - state.entries = [] + state.key = null; + state.entries = []; } else - console.error(`Wrong message: ${message}`) - }) - } + show_error(`Wrong message: ${message}`); + }); +} - browser.runtime.onConnect.addListener(connected); \ No newline at end of file +browser.runtime.onConnect.addListener(connected); \ No newline at end of file diff --git a/icons/export.svg b/icons/export.svg index 56951b1..0e1c8dc 100644 --- a/icons/export.svg +++ b/icons/export.svg @@ -1,3 +1,3 @@ \ No newline at end of file diff --git a/icons/import.svg b/icons/import.svg index 0e1c8dc..56951b1 100644 --- a/icons/import.svg +++ b/icons/import.svg @@ -1,3 +1,3 @@ \ No newline at end of file diff --git a/manifest.json b/manifest.json index d64d489..4f78519 100644 --- a/manifest.json +++ b/manifest.json @@ -16,9 +16,13 @@ }, "manifest_version": 2, "name": "uOTP", + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, "permissions": [ "storage", "clipboardWrite" ], - "version": "1.1.1" + "version": "1.5.1" } \ No newline at end of file diff --git a/options.css b/options.css new file mode 100644 index 0000000..5187838 --- /dev/null +++ b/options.css @@ -0,0 +1,96 @@ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: hsl(213, 10%, 17%); + --light-bg-color: hsl(213, 10%, 23%); + --highlight-bg-color: hsl(213, 10%, 28%); + --fg-color: #ccc; + --border-color: #888; + --error-bg-color: hsl(0, 38%, 30%); + --img-filter: ; + } +} + +@media (prefers-color-scheme: light) { + :root { + --bg-color: #eee; + --light-bg-color: #ddd; + --highlight-bg-color: #ccc; + --fg-color: #666; + --border-color: #888; + --error-bg-color: hsl(0, 38%, 70%); + --img-filter: invert(1); + } +} + +* +{ + box-sizing: border-box; +} + +html, body { + margin: 0; +} + +body { + background-color: var(--bg-color); + color: var(--fg-color); + padding: 12px; + font-size: 16px; +} + +h1 { + margin: 0 0 16px 0; +} + +h1 img { + height: 1em; + margin-right: 16px; +} + +button { + background-color: var(--light-bg-color); + color: var(--fg-color); + border: 1px solid var(--border-color); + margin: 4px; + cursor: pointer; + display: flex; + align-items: center; + border-radius: 5px; + font-size: 1.2rem; +} + +#notification { + position: absolute; + bottom: 0; + z-index: 10; + display: flex; + flex-direction: row; + justify-content: space-between; + background-color: var(--error-bg-color); + padding: 4px; + transform: translateY(100%); + transition: transform 0.3s; +} + +#notification.show { + transform: translateY(0); +} + +#notification img { + height: 1em; + cursor: pointer; +} + +#notification-text { + white-space: pre-wrap; +} + +.action { + display: flex; + flex-direction: row; +} + +.action img { + height: 1.5em; + margin-right: 8px; +} \ No newline at end of file diff --git a/options.html b/options.html new file mode 100644 index 0000000..3618391 --- /dev/null +++ b/options.html @@ -0,0 +1,24 @@ + + +
+ + + + + ++
+