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 @@ + + + + + + + + +

uOTP Options

+
+ + +
+
+
 
+ +
+ + + \ No newline at end of file diff --git a/options.js b/options.js new file mode 100644 index 0000000..878aeab --- /dev/null +++ b/options.js @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..451ad3d --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@stylistic/eslint-plugin-js": "^1.6.3", + "eslint": "^8.57.0" + } +} diff --git a/popup.css b/popup.css index 41dd8d0..eeaa0c7 100644 --- a/popup.css +++ b/popup.css @@ -5,6 +5,7 @@ --highlight-bg-color: hsl(213, 10%, 28%); --fg-color: #ccc; --border-color: #888; + --error-bg-color: hsl(0, 38%, 30%); --img-filter: ; } } @@ -16,16 +17,23 @@ --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 { @@ -77,17 +85,40 @@ 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); } @@ -121,6 +152,21 @@ 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; @@ -148,6 +194,34 @@ 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; } @@ -161,12 +235,20 @@ h2 { margin-right: 8px; } -#store-action { - display: flex; - justify-content: center; -} +@keyframes timer0 { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: 6.28318px; + } + } -#store-action img { - height: 2em; - cursor: pointer; -} \ No newline at end of file + @keyframes timer1 { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: 6.28318px; + } + } \ No newline at end of file diff --git a/popup.html b/popup.html index a06e4ae..2c99ef4 100644 --- a/popup.html +++ b/popup.html @@ -6,11 +6,15 @@ +
+
 
+ +
- +
@@ -21,12 +25,12 @@
- +
- +
@@ -34,10 +38,15 @@
- - + \ No newline at end of file diff --git a/popup.js b/popup.js index 1515f2f..baf23ea 100644 --- a/popup.js +++ b/popup.js @@ -1,408 +1,84 @@ -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') +import { togglePassword, NotificationCenter, Store } from './store.js'; -const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' +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'); -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') - } -} +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"}); async function addEntry(event) { - event.preventDefault() - await store.addEntry(entry_name_elt.value, entry_secret_elt.value) + event.preventDefault(); + // Remove any white spaces (can happen for readability) + await store.addEntry(entry_name_elt.value, entry_secret_elt.value.replace(/\s/g, '')); } function start() { - 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) + password_container_elt.classList.add('hidden'); + [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { + elt.classList.remove('hidden'); }); + store.refresh(); } function stop() { - 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() + [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { + elt.classList.add('hidden'); + }); + password_container_elt.classList.remove('hidden'); + 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() }) + 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: 'init'}) - background_port.onMessage.addListener((state) => { - if(state.key !== null) - store.loadState(state).then(() => start()) - }) + 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}`); + }); } -init().catch(error => console.error(error)) +init().catch(error => { + notification.show_error(error); +}); diff --git a/store.js b/store.js new file mode 100644 index 0000000..ac73f08 --- /dev/null +++ b/store.js @@ -0,0 +1,493 @@ +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'); } ); + } +}