diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index ff2dfbc..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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 645de9c..8234428 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,4 @@ *.zip *.png -.web-extension-id - -node_modules \ No newline at end of file +.web-extension-id \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 7715576..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock = false \ No newline at end of file diff --git a/background.js b/background.js index 8515be2..ff82fbc 100644 --- a/background.js +++ b/background.js @@ -4,75 +4,54 @@ 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}); } -function show_error(error) -{ - console.error(error); - popup_port.postMessage({type: 'notification', data: error}); -} +let popup_port = null // communication channel with popup 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(); - 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}`; + 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}` 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) { - try - { - state.iv = new Uint8Array(b32decode(storage.iv)); - state.salt = new Uint8Array(b32decode(storage.salt)); - } - catch(error) - { - show_error(error); - return; - } + state.iv = new Uint8Array(b32decode(storage.iv)) + state.salt = new Uint8Array(b32decode(storage.salt)) } let encoder = new TextEncoder(); @@ -81,7 +60,7 @@ async function setKey(password) encoder.encode(password), 'PBKDF2', false, - ['deriveKey']); + ['deriveKey']) state.key = await window.crypto.subtle.deriveKey( { name: "PBKDF2", @@ -92,28 +71,17 @@ 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 - send_state(state); + popup_port.postMessage(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( @@ -121,40 +89,42 @@ async function restore(storage) name: "AES-GCM", iv: state.iv }, - state.key, decoded_secret + state.key, + new Uint8Array(b32decode(storage.entries)) ); let decoder = new TextDecoder(); - state.entries = JSON.parse(decoder.decode(decrypted)); - send_state(state); + state.entries = JSON.parse(decoder.decode(decrypted)) + popup_port.postMessage(state) } - catch(_error) + catch (error) { - state.key = null; - show_error('Cannot decrypt entries: wrong password'); + console.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) - send_state(state); + popup_port.postMessage(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 - show_error(`Wrong message: ${message}`); - }); -} + console.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 0e1c8dc..56951b1 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 56951b1..0e1c8dc 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 4f78519..d64d489 100644 --- a/manifest.json +++ b/manifest.json @@ -16,13 +16,9 @@ }, "manifest_version": 2, "name": "uOTP", - "options_ui": { - "page": "options.html", - "open_in_tab": true - }, "permissions": [ "storage", "clipboardWrite" ], - "version": "1.5.1" + "version": "1.1.1" } \ No newline at end of file diff --git a/options.css b/options.css deleted file mode 100644 index 5187838..0000000 --- a/options.css +++ /dev/null @@ -1,96 +0,0 @@ -@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 deleted file mode 100644 index 3618391..0000000 --- a/options.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - -

uOTP Options

-
- - -
-
-
 
- -
- - - \ No newline at end of file diff --git a/options.js b/options.js deleted file mode 100644 index 878aeab..0000000 --- a/options.js +++ /dev/null @@ -1,46 +0,0 @@ -import { NotificationCenter, Store } from './store.js'; - -const export_elt = document.querySelector('#export'); -const import_elt = document.querySelector('#import'); -const notification_elt = document.querySelector('#notification'); -const notification_text_elt = document.querySelector('#notification-text'); - -let notification = new NotificationCenter(notification_elt, notification_text_elt); -let store = new Store(notification); -let background_port = browser.runtime.connect({name:"background"}); - -async function init() -{ - export_elt.addEventListener('click', () => { - if(store.key === null) - notification.show_error('Cannot export before login'); - else - store.export(); - }); - import_elt.addEventListener('click', () => { - if(store.key === null) - notification.show_error('Cannot import before login'); - else - store.import(); - }); - - document.querySelector('#notification img').addEventListener( - 'click', notification.close_notification.bind(notification)); - - background_port.postMessage({command: 'init'}); - background_port.onMessage.addListener((message) => { - if(message.type === 'state') - { - if(message.data.key !== null) - store.loadState(message.data); - } - else if(message.type === 'notification') - notification.show_notification(message.data); - else - notification.show_error(`unexpected background message with type ${message.type}`); - }); -} - -init().catch(error => { - notification.show_error(error); -}); \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index 451ad3d..0000000 --- a/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "devDependencies": { - "@stylistic/eslint-plugin-js": "^1.6.3", - "eslint": "^8.57.0" - } -} diff --git a/popup.css b/popup.css index eeaa0c7..41dd8d0 100644 --- a/popup.css +++ b/popup.css @@ -5,7 +5,6 @@ --highlight-bg-color: hsl(213, 10%, 28%); --fg-color: #ccc; --border-color: #888; - --error-bg-color: hsl(0, 38%, 30%); --img-filter: ; } } @@ -17,23 +16,16 @@ --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; -} - body { background-color: var(--bg-color); color: var(--fg-color); max-height: 600px; margin: 0; font-size: 12px; - position: relative; } input { @@ -85,40 +77,17 @@ h2 { cursor: pointer; } -.entry .otp-container { - display: flex; - align-items: center; -} - .entry .otp { background-color: var(--light-bg-color); padding: 4px 8px; border-radius: 5px; cursor: pointer; - flex-grow: 1; - margin-right: 8px; } .entry .otp:hover { background-color: var(--highlight-bg-color); } -.entry svg { - width: 1.5em; - height: 1.5em; - transform: rotateY(-180deg) rotateZ(-90deg); - border-radius: 50%; -} - -.entry svg circle { - stroke-dasharray: 6.28318px; - stroke-dashoffset: 0; - stroke-width: 100%; - stroke: var(--border-color); - fill: none; - animation: timer1 30s linear 1 forwards; -} - #entry-list .entry { border-top: 1px solid var(--border-color); } @@ -152,21 +121,6 @@ h2 { right: 10px; } -#action { - display: flex; - justify-content: center; -} - -#action > div { - display: flex; - justify-content: center; -} - -#action img { - height: 2em; - cursor: pointer; -} - #entry-add-container { position: relative; padding-bottom: 4px; @@ -194,34 +148,6 @@ h2 { cursor: pointer; } -#notification { - position: absolute; - top: 0; - left: 0; - right: 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; -} - #password-container { padding: 1em; } @@ -235,20 +161,12 @@ h2 { margin-right: 8px; } -@keyframes timer0 { - from { - stroke-dashoffset: 0; - } - to { - stroke-dashoffset: 6.28318px; - } - } +#store-action { + display: flex; + justify-content: center; +} - @keyframes timer1 { - from { - stroke-dashoffset: 0; - } - to { - stroke-dashoffset: 6.28318px; - } - } \ No newline at end of file +#store-action img { + height: 2em; + cursor: pointer; +} \ No newline at end of file diff --git a/popup.html b/popup.html index 2c99ef4..a06e4ae 100644 --- a/popup.html +++ b/popup.html @@ -6,15 +6,11 @@ -
-
 
- -
- +
@@ -25,12 +21,12 @@
- +
- +
@@ -38,15 +34,10 @@
-
- - +
- + \ No newline at end of file diff --git a/popup.js b/popup.js index baf23ea..1515f2f 100644 --- a/popup.js +++ b/popup.js @@ -1,84 +1,408 @@ -import { togglePassword, NotificationCenter, Store } from './store.js'; +const password_container_elt = document.querySelector('#password-container') +const password_form_elt = document.querySelector('#password-form') +const master_password_elt = document.querySelector('#master-password') +const master_visibility_elt = document.querySelector('#master-visibility') +const entry_add_container_elt = document.querySelector('#entry-add-container') +const logout_elt = document.querySelector('#logout') +const store_action_elt = document.querySelector('#store-action') +const export_elt = document.querySelector('#export') +const entry_form_elt = document.querySelector('#entry-form') +const entry_name_elt = document.querySelector('#entry-name') +const entry_secret_elt = document.querySelector('#entry-secret') +const entry_visibility_elt = document.querySelector('#entry-visibility') +const entry_list_elt = document.querySelector('#entry-list') -const password_container_elt = document.querySelector('#password-container'); -const password_form_elt = document.querySelector('#password-form'); -const master_password_elt = document.querySelector('#master-password'); -const master_visibility_elt = document.querySelector('#master-visibility'); -const entry_add_container_elt = document.querySelector('#entry-add-container'); -const logout_elt = document.querySelector('#logout'); -const store_action_elt = document.querySelector('#store-action'); -const export_elt = document.querySelector('#export'); -const import_action_elt = document.querySelector('#import-action'); -const import_elt = document.querySelector('#import'); -const entry_form_elt = document.querySelector('#entry-form'); -const entry_name_elt = document.querySelector('#entry-name'); -const entry_secret_elt = document.querySelector('#entry-secret'); -const entry_visibility_elt = document.querySelector('#entry-visibility'); -const entry_list_elt = document.querySelector('#entry-list'); -const notification_elt = document.querySelector('#notification'); -const notification_text_elt = document.querySelector('#notification-text'); +const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' -let notification = new NotificationCenter(notification_elt, notification_text_elt); -let store = new Store(notification, entry_list_elt); -let background_port = browser.runtime.connect({name:"background"}); +function togglePassword(elt) +{ + if(elt.getAttribute('type') === 'password') + elt.setAttribute('type', 'text') + else + elt.setAttribute('type', 'password') +} + +function b32encode(bytes) +{ + let result = [] + let index = 0 + let lshift = 0 + let carry = 0 + let byte = bytes[index] + while(index < bytes.length) + { + if(lshift < 0) // need to right shift (should have a carry) + { + result.push(b32chars[carry + (byte >>> (-lshift + 3))]) + carry = 0 + lshift += 5 + } + else if(lshift > 3) // not enough bits to convert (save carry, next will be right shift) + { + carry = ((byte << lshift) & 248) >> 3 + lshift -= 8 + index += 1 + byte = bytes[index] + } + else // enough bits to convert + { + result.push(b32chars[((byte << lshift) & 248) >> 3]) + lshift += 5 + } + } + if(carry !== 0) // if carry left, add has last char + result.push(b32chars[carry]) + return result.join('') +} + +function b32decode(text) +{ + const up_text = text.toUpperCase() + let result = [] + let lshift = 3 + let carry = 0 + for(const index in up_text) + { + 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}` + + if(lshift > 0) // next value is needed to complete the octet + { + carry += value << lshift + lshift -= 5 + if(lshift === 0) // last value aligned with octet + 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 + } + } + if(carry !== 0) + result.push(carry) + return result +} + +let refresh_timeout = null + +class Store +{ + constructor() + { + this.entries = [] + this.iv = null + this.salt = null + this.key = null + } + + async loadState(state) + { + this.iv = state.iv + this.salt = state.salt + this.key = state.key + for(const entry of state.entries) + { + await store.addEntry(entry.name, entry.secret, false) + } + store.save() + } + + async addEntry(name, secret, save=true) + { + const entry = await Entry.create(name, secret) + entry.insert() + this.entries.push(entry) + if(save) + this.save() + } + + async removeEntry(entry) + { + entry.remove() + this.entries = this.entries.filter(test_entry => test_entry !== entry) + this.save() + } + + async save() + { + if(this.key === null) + throw `Cannot save store without key` + let encoder = new TextEncoder(); + const encrypted = await window.crypto.subtle.encrypt( + {name: 'AES-GCM', iv: this.iv}, + this.key, + encoder.encode(JSON.stringify( + this.entries.map(entry => {return {name: entry.name, secret: entry.secret}})))) + await browser.storage.local.set({ + iv: b32encode(this.iv), + salt: b32encode(this.salt), + entries: b32encode(new Uint8Array(encrypted)) + }) + } + + refresh() + { + const now = Date.now() + if(refresh_timeout !== null) + clearTimeout(refresh_timeout) + refresh_timeout = setTimeout( + () => { store.refresh() }, + ((Math.floor(now / 30000) + 1) * 30000) - now + 1) + + const counter = Math.floor(now / 30000) + for(const entry of this.entries) + { + entry.refresh(counter) + } + } + + close() + { + this.entries.forEach(entry => { entry.remove() }) + this.entries = [] + this.key = null + } + + export() + { + let export_elt = document.createElement('a') + export_elt.setAttribute('href', 'data:application/json,' + JSON.stringify( + this.entries.map(entry => {return {name: entry.name, secret: entry.secret}}))) + export_elt.setAttribute('download', 'uotp_export.json') + export_elt.style.display = 'none' + document.body.appendChild(export_elt) + export_elt.click() + document.body.removeChild(export_elt) + } +} + +let store = new Store() + +class Entry +{ + constructor(name, secret, key) + { + this.name = name + this.secret = secret + this.key = key + + let new_elt = document.createElement('div') + new_elt.className = 'entry' + + let header_elt = document.createElement('div') + header_elt.className = 'header' + // Entry title + let title_elt = document.createElement('h2') + title_elt.textContent = name + header_elt.appendChild(title_elt) + + // Entry actions + let action_elt = document.createElement('div') + action_elt.className = "action" + let edit_elt = document.createElement('img') + edit_elt.src = "/icons/edit.svg" + edit_elt.title = 'Edit' + edit_elt.addEventListener('click', () => {this.openEdit()}) + action_elt.appendChild(edit_elt) + let delete_elt = document.createElement('img') + delete_elt.src = "/icons/delete.svg" + delete_elt.title = 'Delete' + delete_elt.addEventListener('click', () => {store.removeEntry(this)}) + action_elt.appendChild(delete_elt) + header_elt.appendChild(action_elt) + + new_elt.appendChild(header_elt) + + // Entry OTP + let otp_elt = document.createElement('div') + otp_elt.className = 'otp' + otp_elt.addEventListener('click', () => { + navigator.clipboard.writeText(otp_elt.textContent) + }) + new_elt.appendChild(otp_elt) + + this.elements = { + main: new_elt, + header: header_elt, + title: title_elt, + otp: otp_elt, + action: action_elt, + edit: edit_elt, + delete: delete_elt + } + } + + // static function to construct Entry while being async + static async create(name, secret) + { + const key = await window.crypto.subtle.importKey( + 'raw', new Uint8Array(b32decode(secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) + const new_entry = new Entry(name, secret, key) + await new_entry.refresh(Math.floor(Date.now() / 30000)) + return new_entry + } + + async regenerateKey() + { + this.key = await window.crypto.subtle.importKey( + 'raw', new Uint8Array(b32decode(this.secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) + } + + async refresh(counter) + { + // TOTP algorithm is here + + // Set timestamp as byte array for hmac hash (4 bytes is enough) + const buffer = new ArrayBuffer(8); // 8 byte buffer needed for HMAC (initialized with 0) + const view = new DataView(buffer); + view.setUint32(4, counter) + // HMAC returns am ArrayBuffer wich is unusable as is (no type), using Uint8Array is needed + const signature = new Uint8Array(await window.crypto.subtle.sign("hmac", this.key, buffer)) + // Getting the offset from the last byte with its highest bit masked to 0 + const offset = signature[signature.length - 1] & 15 + // Masking the highest bit of the byte located at calculated offset + signature[offset] = signature[offset] & 127 + // Reading 4 bytes from the hash from the offset (highest byte was masked) and getting the last + // 6 digits from its Uint32 representation + this.elements.otp.textContent = new DataView( + signature.slice(offset, offset + 4).buffer).getUint32().toString().slice(-6) + } + + insert() + { + entry_list_elt.appendChild(this.elements.main) + } + + remove() + { + entry_list_elt.removeChild(this.elements.main) + } + + openEdit() + { + this.elements.title.classList.add('hidden') + this.elements.otp.classList.add('hidden') + this.elements.edit.classList.add('hidden') + this.elements.delete.classList.add('hidden') + + let name_input_elt = document.createElement('input') + name_input_elt.value = this.name + this.elements.header.insertBefore(name_input_elt, this.elements.action) + + let secret_container_elt = document.createElement('div') + secret_container_elt.className = 'password-container' + let secret_input_elt = document.createElement('input') + secret_input_elt.setAttribute('type', 'password') + secret_input_elt.value = this.secret + secret_container_elt.appendChild(secret_input_elt) + let secret_visibility_elt = document.createElement('img') + secret_visibility_elt.className = 'icon visibility' + secret_visibility_elt.src = '/icons/visibility.svg' + secret_container_elt.appendChild(secret_visibility_elt) + secret_visibility_elt.addEventListener('click', () => {togglePassword(secret_input_elt)}) + this.elements.main.appendChild(secret_container_elt) + + let done_elt = document.createElement('img') + done_elt.src = "/icons/done.svg" + done_elt = 'Save' + this.elements.action.appendChild(done_elt) + let close_elt = document.createElement('img') + close_elt.src = "/icons/close.svg" + close_elt.title = 'Cancel' + this.elements.action.appendChild(close_elt) + + done_elt.addEventListener('click', () => { + this.name = name_input_elt.value + this.elements.title.textContent = this.name + this.secret = secret_input_elt.value + this.regenerateKey().then(() => { this.refresh(Math.floor(Date.now() / 30000)) }) + store.save() + this.elements.main.removeChild(secret_container_elt) + this.elements.action.removeChild(done_elt) + this.elements.action.removeChild(close_elt) + this.elements.header.removeChild(name_input_elt) + this.closeEdit() + }) + close_elt.addEventListener('click', () => { + this.elements.main.removeChild(secret_container_elt) + this.elements.action.removeChild(done_elt) + this.elements.action.removeChild(close_elt) + this.elements.header.removeChild(name_input_elt) + this.closeEdit() + }) + } + + closeEdit() + { + this.elements.title.classList.remove('hidden') + this.elements.otp.classList.remove('hidden') + this.elements.edit.classList.remove('hidden') + this.elements.delete.classList.remove('hidden') + } +} async function addEntry(event) { - event.preventDefault(); - // Remove any white spaces (can happen for readability) - await store.addEntry(entry_name_elt.value, entry_secret_elt.value.replace(/\s/g, '')); + event.preventDefault() + await store.addEntry(entry_name_elt.value, entry_secret_elt.value) } function start() { - password_container_elt.classList.add('hidden'); - [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { - elt.classList.remove('hidden'); }); - store.refresh(); + password_container_elt.classList.add('hidden') + store_action_elt.classList.remove('hidden') + entry_add_container_elt.classList.remove('hidden') + const now = Date.now() + if(refresh_timeout !== null) + clearTimeout(refresh_timeout) + refresh_timeout = setTimeout( + () => { + store.refresh() + }, + ((Math.floor(now / 30000) + 1) * 30000) - now + 1) } function stop() { - [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { - elt.classList.add('hidden'); - }); - password_container_elt.classList.remove('hidden'); - store.close(); + store_action_elt.classList.add('hidden') + entry_add_container_elt.classList.add('hidden') + password_container_elt.classList.remove('hidden') + if(refresh_timeout !== null) + clearTimeout(refresh_timeout) + store.close() } +let background_port = browser.runtime.connect({name:"background"}); + async function init() { password_form_elt.addEventListener('submit', (event) => { - event.preventDefault(); - background_port.postMessage({command: 'key', password: master_password_elt.value}); - master_password_elt.value = ''; - }); - master_visibility_elt.addEventListener('click', () => {togglePassword(master_password_elt);}); - entry_form_elt.addEventListener('submit', addEntry); - entry_visibility_elt.addEventListener('click', () => {togglePassword(entry_secret_elt);}); + event.preventDefault() + background_port.postMessage({command: 'key', password: master_password_elt.value}) + master_password_elt.value = '' + }) + master_visibility_elt.addEventListener('click', () => {togglePassword(master_password_elt)}) + entry_form_elt.addEventListener('submit', addEntry) + entry_visibility_elt.addEventListener('click', () => {togglePassword(entry_secret_elt)}) logout_elt.addEventListener('click', () => { - background_port.postMessage({command: 'logout', password: master_password_elt.value}); - stop(); - }); - export_elt.addEventListener('click', () => { store.export(); }); - import_elt.addEventListener('click', () => { browser.tabs.create({ url: "options.html" }); }); - document.querySelector('#notification img').addEventListener( - 'click', notification.close_notification.bind(notification)); + background_port.postMessage({command: 'logout', password: master_password_elt.value}) + stop() + }) + export_elt.addEventListener('click', () => { store.export() }) - background_port.postMessage({command: 'init'}); - background_port.onMessage.addListener((message) => { - if(message.type === 'state') - { - if(message.data.key !== null) - store.loadState(message.data).then(start); - } - else if(message.type === 'notification') - notification.show_notification(message.data); - else - notification.show_error(`unexpected background message with type ${message.type}`); - }); + background_port.postMessage({command: 'init'}) + background_port.onMessage.addListener((state) => { + if(state.key !== null) + store.loadState(state).then(() => start()) + }) } -init().catch(error => { - notification.show_error(error); -}); +init().catch(error => console.error(error)) diff --git a/store.js b/store.js deleted file mode 100644 index ac73f08..0000000 --- a/store.js +++ /dev/null @@ -1,493 +0,0 @@ -const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - -function b32encode(bytes) -{ - let result = []; - let index = 0; - let lshift = 0; - let carry = 0; - let byte = bytes[index]; - while(index < bytes.length) - { - if(lshift < 0) // need to right shift (should have a carry) - { - result.push(b32chars[carry + (byte >>> (-lshift + 3))]); - carry = 0; - lshift += 5; - } - else if(lshift > 3) // not enough bits to convert (save carry, next will be right shift) - { - carry = ((byte << lshift) & 248) >> 3; - lshift -= 8; - index += 1; - byte = bytes[index]; - } - else // enough bits to convert - { - result.push(b32chars[((byte << lshift) & 248) >> 3]); - lshift += 5; - } - } - if(carry !== 0) // if carry left, add has last char - result.push(b32chars[carry]); - return result.join(''); -} - -function b32decode(text) -{ - const up_text = text.toUpperCase(); - let result = []; - let lshift = 3; - let carry = 0; - for(const index in up_text) - { - const char = up_text[index]; - if(char === '=') - 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; - if(lshift === 0) // last value aligned with octet - 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; - } - } - if(carry !== 0) - result.push(carry); - return result; -} - -export function togglePassword(elt) -{ - if(elt.getAttribute('type') === 'password') - elt.setAttribute('type', 'text'); - else - elt.setAttribute('type', 'password'); -} - -export class NotificationCenter -{ - constructor(notification_elt, notification_text_elt, notification_delay=5000, notification_repeat=500) - { - this.notifications = []; - this.notification_timeout = null; - this.notification_elt = notification_elt; - this.notification_text_elt = notification_text_elt; - this.notification_delay = notification_delay; - this.notification_repeat = notification_repeat; - } - - show_notification(text) - { - this.notifications.push(text); - this._notification(); - } - - show_error(error) - { - console.error(error); - this.show_notification(error); - } - - close_notification() - { - clearTimeout(this.notification_timeout); - this._next_notification(); - } - - _next_notification() - { - this.notification_timeout = null; - this.notification_elt.classList.remove('show'); - this.notifications.shift(); - if(this.notifications.length !== 0) - setTimeout(this._notification.bind(this), this.notification_repeat); - } - - _notification() - { - if(this.notifications.length !== 0 && this.notification_timeout === null) - { - this.notification_text_elt.firstChild.data = this.notifications[0]; - this.notification_elt.classList.add('show'); - this.notification_timeout = setTimeout(this._next_notification.bind(this), this.notification_delay); - } - } -} - -export class Store -{ - constructor(notification_center, entry_list_elt=null) - { - this.entries = []; - this.iv = null; - this.salt = null; - this.key = null; - - this.refresh_timeout = null; - - this.entry_list_elt = entry_list_elt; - this.notification_center = notification_center; - } - - async loadState(state) - { - this.iv = state.iv; - this.salt = state.salt; - this.key = state.key; - for(const entry of state.entries) - { - await this.addEntry(entry.name, entry.secret, false); - } - this.save(); - } - - async addEntry(name, secret, save=true) - { - const entry = await Entry.create(this, name, secret, this.notification_center); - if(entry === null) - return; - if(this.entry_list_elt !== null) - this.entry_list_elt.appendChild(entry.elements.main); - this.entries.push(entry); - if(save) - this.save(); - } - - async removeEntry(entry) - { - if(this.entry_list_elt !== null) - this.entry_list_elt.removeChild(entry.elements.main); - this.entries = this.entries.filter(test_entry => test_entry !== entry); - this.save(); - } - - async save() - { - if(this.key === null) - { - this.notification_center.show_error(`Cannot save store without key`); - return; - } - let encoder = new TextEncoder(); - const encrypted = await window.crypto.subtle.encrypt( - {name: 'AES-GCM', iv: this.iv}, - this.key, - encoder.encode(JSON.stringify( - this.entries.map(entry => {return {name: entry.name, secret: entry.secret};})))); - await browser.storage.local.set({ - iv: b32encode(this.iv), - salt: b32encode(this.salt), - entries: b32encode(new Uint8Array(encrypted)) - }); - } - - refresh() - { - const now = Date.now(); - const count_time = now / 30000; - const counter = Math.floor(count_time); - const animation_delay = (count_time - counter) * 30; - - if(this.refresh_timeout !== null) - clearTimeout(this.refresh_timeout); - this.refresh_timeout = setTimeout( - () => { this.refresh(); }, - ((counter + 1) * 30000) - now + 1); - - for(const entry of this.entries) - { - entry.refresh(counter); - entry.refreshTimer(animation_delay); - } - } - - close() - { - if(this.refresh_timeout !== null) - clearTimeout(this.refresh_timeout); - if(this.entry_list_elt !== null) - this.entries.forEach(entry => { this.entry_list_elt.removeChild(entry.elements.main); }); - this.entries = []; - this.key = null; - } - - export() - { - const export_elt = document.createElement('a'); - export_elt.setAttribute('href', 'data:application/json,' + JSON.stringify( - this.entries.map(entry => {return {name: entry.name, secret: entry.secret};}))); - export_elt.setAttribute('download', 'uotp_export.json'); - export_elt.style.display = 'none'; - document.body.appendChild(export_elt); - export_elt.click(); - document.body.removeChild(export_elt); - } - - import() - { - const import_elt = document.createElement('input'); - import_elt.setAttribute('type', 'file'); - import_elt.onchange = event => { - if(event.target.files.length > 0) - { - let reader = new FileReader(); - reader.onload = () => { - let import_json = null; - try - { - import_json = JSON.parse(reader.result); - } - catch(error) - { - this.notification_center.show_error('Error reading import file : not a valid json.'); - } - if(!Array.isArray(import_json)) - { - this.notification_center.show_error( - 'Invalid imported file : expecting a list of entries, imported JSON is not a list'); - return; - } - const errors = []; - import_json.forEach((entry, index) => { - if(!entry.name) - { - errors.push(`Invalid entry #${index} in imported file : "name" not found`); - return; - } - if(!entry.secret) - { - errors.push(`Invalid entry #${index} in imported file : "secret" not found`); - return; - } - this.addEntry(entry.name, entry.secret); - }); - if(errors.length > 0) - this.notification_center.show_error( - `${errors.length} errors in imported file :\n${errors.join('\n')}`); - }; - reader.readAsText(event.target.files[0]); - } - }; - document.body.appendChild(import_elt); - import_elt.click(); - document.body.removeChild(import_elt); - } -} - -class Entry -{ - constructor(store, name, secret, key) - { - this.store = store; - this.name = name; - this.secret = secret; - this.key = key; - this.timer_count = 0; - - let new_elt = document.createElement('div'); - new_elt.className = 'entry'; - - let header_elt = document.createElement('div'); - header_elt.className = 'header'; - // Entry title - let title_elt = document.createElement('h2'); - title_elt.textContent = name; - header_elt.appendChild(title_elt); - - // Entry actions - let action_elt = document.createElement('div'); - action_elt.className = "action"; - let edit_elt = document.createElement('img'); - edit_elt.src = "/icons/edit.svg"; - edit_elt.title = 'Edit'; - edit_elt.addEventListener('click', () => {this.openEdit();}); - action_elt.appendChild(edit_elt); - let delete_elt = document.createElement('img'); - delete_elt.src = "/icons/delete.svg"; - delete_elt.title = 'Delete'; - delete_elt.addEventListener('click', () => { this.store.removeEntry(this); }); - action_elt.appendChild(delete_elt); - header_elt.appendChild(action_elt); - - new_elt.appendChild(header_elt); - - // Entry OTP - let otp_container_elt = document.createElement('div'); - otp_container_elt.className = 'otp-container'; - let otp_elt = document.createElement('div'); - otp_elt.className = 'otp'; - otp_elt.addEventListener('click', () => { - navigator.clipboard.writeText(otp_elt.textContent); - }); - otp_container_elt.appendChild(otp_elt); - // let svg_timer_elt = document.createElement('svg') - const svgNS = 'http://www.w3.org/2000/svg'; - let svg_timer_elt = document.createElementNS(svgNS, 'svg'); - svg_timer_elt.setAttributeNS(null, 'viewBox', '0 0 2 2'); - let timer_circle_elt = document.createElementNS(svgNS, 'circle'); - timer_circle_elt.setAttributeNS(null, 'r', '1'); - timer_circle_elt.setAttributeNS(null, 'cx', '1'); - timer_circle_elt.setAttributeNS(null, 'cy', '1'); - svg_timer_elt.appendChild(timer_circle_elt); - otp_container_elt.appendChild(svg_timer_elt); - new_elt.appendChild(otp_container_elt); - - this.elements = { - main: new_elt, - header: header_elt, - title: title_elt, - otp: otp_elt, - svg_timer_elt: svg_timer_elt, - timer_circle: timer_circle_elt, - action: action_elt, - edit: edit_elt, - delete: delete_elt - }; - } - - // static function to construct Entry while being async - static async create(store, name, secret, notification_center) - { - let decoded_secret = ''; - try - { - decoded_secret = new Uint8Array(b32decode(secret)); - } - catch(error) - { - notification_center.show_error(error); - return null; - } - const key = await window.crypto.subtle.importKey( - 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']); - const new_entry = new Entry(store, name, secret, key); - const count_time = Date.now() / 30000; - const counter = Math.floor(count_time); - await new_entry.refresh(counter); - new_entry.refreshTimer((count_time - counter) * 30); - return new_entry; - } - - async regenerateKey() - { - let decoded_secret = ''; - try - { - decoded_secret = new Uint8Array(b32decode(this.secret)); - } - catch(error) - { - this.notification_center.show_error(error); - return; - } - - this.key = await window.crypto.subtle.importKey( - 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']); - } - - async refresh(counter) - { - // TOTP algorithm is here - - // Set timestamp as byte array for hmac hash (4 bytes is enough) - const buffer = new ArrayBuffer(8); // 8 byte buffer needed for HMAC (initialized with 0) - const view = new DataView(buffer); - view.setUint32(4, counter); - // HMAC returns am ArrayBuffer wich is unusable as is (no type), using Uint8Array is needed - const signature = new Uint8Array(await window.crypto.subtle.sign("hmac", this.key, buffer)); - // Getting the offset from the last byte with its highest bit masked to 0 - const offset = signature[signature.length - 1] & 15; - // Masking the highest bit of the byte located at calculated offset - signature[offset] = signature[offset] & 127; - // Reading 4 bytes from the hash from the offset (highest byte was masked) and getting the last - // 6 digits from its Uint32 representation - this.elements.otp.textContent = new DataView( - signature.slice(offset, offset + 4).buffer).getUint32().toString().slice(-6); - } - - refreshTimer(animation_delay) - { - this.timer_count = 1 - this.timer_count; - this.elements.timer_circle.style.animationDelay = `-${animation_delay}s`; - this.elements.timer_circle.style.animationName = `timer${this.timer_count}`; - } - - openEdit() - { - [this.elements.title, this.elements.otp, this.elements.edit, this.elements.delete, - this.elements.svg_timer_elt].forEach(elt => {elt.classList.add('hidden');} ); - - let name_input_elt = document.createElement('input'); - name_input_elt.value = this.name; - this.elements.header.insertBefore(name_input_elt, this.elements.action); - - let secret_container_elt = document.createElement('div'); - secret_container_elt.className = 'password-container'; - let secret_input_elt = document.createElement('input'); - secret_input_elt.setAttribute('type', 'password'); - secret_input_elt.value = this.secret; - secret_container_elt.appendChild(secret_input_elt); - let secret_visibility_elt = document.createElement('img'); - secret_visibility_elt.className = 'icon visibility'; - secret_visibility_elt.src = '/icons/visibility.svg'; - secret_container_elt.appendChild(secret_visibility_elt); - secret_visibility_elt.addEventListener('click', () => {togglePassword(secret_input_elt);}); - this.elements.main.appendChild(secret_container_elt); - - let done_elt = document.createElement('img'); - done_elt.src = "/icons/done.svg"; - done_elt.title = 'Save'; - this.elements.action.appendChild(done_elt); - let close_elt = document.createElement('img'); - close_elt.src = "/icons/close.svg"; - close_elt.title = 'Cancel'; - this.elements.action.appendChild(close_elt); - - done_elt.addEventListener('click', () => { - this.name = name_input_elt.value; - this.elements.title.textContent = this.name; - // Remove any white spaces (can happen for readability) - this.secret = secret_input_elt.value.replace(/\s/g, ''); - this.regenerateKey().then(() => { - const count_time = Date.now() / 30000; - const counter = Math.floor(count_time); - this.refresh(counter); - this.refreshTimer((count_time - counter) * 30); - }); - this.store.save(); - this.elements.main.removeChild(secret_container_elt); - this.elements.action.removeChild(done_elt); - this.elements.action.removeChild(close_elt); - this.elements.header.removeChild(name_input_elt); - this.closeEdit(); - }); - close_elt.addEventListener('click', () => { - this.elements.main.removeChild(secret_container_elt); - this.elements.action.removeChild(done_elt); - this.elements.action.removeChild(close_elt); - this.elements.header.removeChild(name_input_elt); - this.closeEdit(); - }); - } - - closeEdit() - { - [this.elements.title, this.elements.otp, this.elements.edit, this.elements.delete, - this.elements.svg_timer_elt].forEach(elt => { elt.classList.remove('hidden'); } ); - } -}