From d8527409983205386f9962cc46238ad7eaa07462 Mon Sep 17 00:00:00 2001 From: Corentin Date: Wed, 18 Jan 2023 17:47:08 +0900 Subject: [PATCH 01/10] Add visual timer --- popup.css | 43 ++++++++++++++++++++++++++++++++++++++++++- popup.js | 44 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/popup.css b/popup.css index 41dd8d0..51953cd 100644 --- a/popup.css +++ b/popup.css @@ -77,17 +77,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); } @@ -169,4 +192,22 @@ h2 { #store-action img { height: 2em; cursor: pointer; -} \ No newline at end of file +} + +@keyframes timer0 { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: 6.28318px; + } + } + + @keyframes timer1 { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: 6.28318px; + } + } \ No newline at end of file diff --git a/popup.js b/popup.js index 1515f2f..01860c6 100644 --- a/popup.js +++ b/popup.js @@ -151,16 +151,20 @@ class Store refresh() { const now = Date.now() + const count_time = now / 30000 + const counter = Math.floor(count_time) + const animation_delay = (count_time - counter) * 30 + if(refresh_timeout !== null) clearTimeout(refresh_timeout) refresh_timeout = setTimeout( () => { store.refresh() }, - ((Math.floor(now / 30000) + 1) * 30000) - now + 1) + ((counter + 1) * 30000) - now + 1) - const counter = Math.floor(now / 30000) for(const entry of this.entries) { entry.refresh(counter) + entry.refreshTimer(animation_delay) } } @@ -193,6 +197,7 @@ class Entry this.name = name this.secret = secret this.key = key + this.timer_count = 0 let new_elt = document.createElement('div') new_elt.className = 'entry' @@ -222,18 +227,32 @@ class Entry 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) }) - new_elt.appendChild(otp_elt) + 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, + timer_circle: timer_circle_elt, action: action_elt, edit: edit_elt, delete: delete_elt @@ -246,7 +265,10 @@ class Entry 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)) + 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 } @@ -276,6 +298,13 @@ class Entry 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}` + } + insert() { entry_list_elt.appendChild(this.elements.main) @@ -323,7 +352,12 @@ class Entry 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)) }) + this.regenerateKey().then(() => { + const count_time = Date.now() / 30000 + const counter = Math.floor(count_time) + this.refresh(counter) + this.refreshTimer((count_time - counter) * 30) + }) store.save() this.elements.main.removeChild(secret_container_elt) this.elements.action.removeChild(done_elt) From 372ab0e07e05786688747fade2fc9ad1967060d2 Mon Sep 17 00:00:00 2001 From: Corentin Date: Wed, 18 Jan 2023 17:49:31 +0900 Subject: [PATCH 02/10] Bump to version 1.2.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index d64d489..a4e3862 100644 --- a/manifest.json +++ b/manifest.json @@ -20,5 +20,5 @@ "storage", "clipboardWrite" ], - "version": "1.1.1" + "version": "1.2.0" } \ No newline at end of file From 718315d7c4fd7695188a81dc178decfeb83eb1d4 Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 20 Mar 2023 06:12:08 +0900 Subject: [PATCH 03/10] Add notifications * Fix entry edit --- background.js | 69 ++++++++++++++++++++--------- popup.css | 32 ++++++++++++++ popup.html | 4 ++ popup.js | 120 +++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 185 insertions(+), 40 deletions(-) diff --git a/background.js b/background.js index ff82fbc..92a2d42 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,17 @@ let state = { 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}) +} + function b32decode(text) { const up_text = text.toUpperCase() @@ -19,11 +30,13 @@ function b32decode(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}` + 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 { @@ -50,8 +63,16 @@ async function setKey(password) 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(); @@ -76,12 +97,23 @@ async function setKey(password) if(storage.entries !== undefined) 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,17 +121,16 @@ 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) + send_state(state) } - catch (error) + catch(_error) { - console.error('Cannot decrypt entries: wrong password') + show_error('Cannot decrypt entries: wrong password') } } @@ -109,11 +140,9 @@ function connected(port) { 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) @@ -123,8 +152,8 @@ function connected(port) { 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/popup.css b/popup.css index 51953cd..dbb5378 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 { @@ -171,6 +179,30 @@ 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); + font-size: 1.2em; + padding: 4px; + transform: translateY(-100%); + transition: transform 0.3s; +} + +#notification.show { + transform: translateY(0); +} + +#notification img { + height: 1em; +} + #password-container { padding: 1em; } diff --git a/popup.html b/popup.html index a06e4ae..0e165e0 100644 --- a/popup.html +++ b/popup.html @@ -6,6 +6,10 @@ +
+ + +
diff --git a/popup.js b/popup.js index 01860c6..9d9a2a4 100644 --- a/popup.js +++ b/popup.js @@ -11,8 +11,16 @@ 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' +const notification_delay = 5000 +const notification_repeat = 500 + +let notifications = [] +let notification_timeout = null +let refresh_timeout = null function togglePassword(elt) { @@ -66,10 +74,12 @@ function b32decode(text) const char = up_text[index] if(char === '=') break - let value = char.charCodeAt() - 65 - if(value < 0) - value += 41 - if(value < 0 || value > 31) + 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 @@ -91,7 +101,42 @@ function b32decode(text) return result } -let refresh_timeout = null +function show_notification(text) +{ + notifications.push(text) + _notification() +} + +function show_error(error) +{ + console.error(error) + show_notification(error) +} + +function close_notification() +{ + clearTimeout(notification_timeout) + _next_notification() +} + +function _next_notification() +{ + notification_timeout = null + notification_elt.classList.remove('show') + notifications.shift() + if(notifications.length !== 0) + setTimeout(_notification, notification_repeat) +} + +function _notification() +{ + if(notifications.length !== 0 && notification_timeout === null) + { + notification_text_elt.innerHTML = notifications[0] + notification_elt.classList.add('show') + notification_timeout = setTimeout(_next_notification, notification_delay) + } +} class Store { @@ -118,6 +163,8 @@ class Store async addEntry(name, secret, save=true) { const entry = await Entry.create(name, secret) + if(entry === null) + return entry.insert() this.entries.push(entry) if(save) @@ -134,7 +181,10 @@ class Store async save() { if(this.key === null) - throw `Cannot save store without key` + { + 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}, @@ -252,6 +302,7 @@ class Entry 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, @@ -262,8 +313,18 @@ class Entry // static function to construct Entry while being async static async create(name, secret) { + let decoded_secret = '' + try + { + decoded_secret = new Uint8Array(b32decode(secret)) + } + catch(error) + { + show_error(error) + return null + } const key = await window.crypto.subtle.importKey( - 'raw', new Uint8Array(b32decode(secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) + 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) const new_entry = new Entry(name, secret, key) const count_time = Date.now() / 30000 const counter = Math.floor(count_time) @@ -274,8 +335,19 @@ class Entry async regenerateKey() { + let decoded_secret = '' + try + { + decoded_secret = new Uint8Array(b32decode(this.secret)) + } + catch(error) + { + show_error(error) + return + } + this.key = await window.crypto.subtle.importKey( - 'raw', new Uint8Array(b32decode(this.secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) + 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) } async refresh(counter) @@ -321,6 +393,7 @@ class Entry this.elements.otp.classList.add('hidden') this.elements.edit.classList.add('hidden') this.elements.delete.classList.add('hidden') + this.elements.svg_timer_elt.classList.add('hidden') let name_input_elt = document.createElement('input') name_input_elt.value = this.name @@ -341,7 +414,7 @@ class Entry let done_elt = document.createElement('img') done_elt.src = "/icons/done.svg" - done_elt = 'Save' + done_elt.title = 'Save' this.elements.action.appendChild(done_elt) let close_elt = document.createElement('img') close_elt.src = "/icons/close.svg" @@ -380,6 +453,7 @@ class Entry this.elements.otp.classList.remove('hidden') this.elements.edit.classList.remove('hidden') this.elements.delete.classList.remove('hidden') + this.elements.svg_timer_elt.classList.remove('hidden') } } @@ -396,12 +470,8 @@ function start() 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) + clearTimeout(refresh_timeout) + refresh_timeout = setTimeout(() => { store.refresh() }, ((Math.floor(now / 30000) + 1) * 30000) - now + 1) } function stop() @@ -410,7 +480,7 @@ function stop() entry_add_container_elt.classList.add('hidden') password_container_elt.classList.remove('hidden') if(refresh_timeout !== null) - clearTimeout(refresh_timeout) + clearTimeout(refresh_timeout) store.close() } @@ -431,12 +501,22 @@ async function init() stop() }) export_elt.addEventListener('click', () => { store.export() }) + document.querySelector('#notification img').addEventListener('click', close_notification) background_port.postMessage({command: 'init'}) - background_port.onMessage.addListener((state) => { - if(state.key !== null) - store.loadState(state).then(() => start()) + 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') + show_notification(message.data) + else + show_error(`unexpected background message with type ${message.type}`) }) } -init().catch(error => console.error(error)) +init().catch(error => { + show_error(error) +}) From d2baffb5d441b0b9bc285d48922ee3c008a41f62 Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 20 Mar 2023 06:26:20 +0900 Subject: [PATCH 04/10] Remove white spaces from secrets Fix #1 --- popup.html | 6 +++--- popup.js | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/popup.html b/popup.html index 0e165e0..7bad604 100644 --- a/popup.html +++ b/popup.html @@ -14,7 +14,7 @@
- +
@@ -25,12 +25,12 @@
- +
- +
diff --git a/popup.js b/popup.js index 9d9a2a4..6caccc4 100644 --- a/popup.js +++ b/popup.js @@ -424,7 +424,8 @@ class Entry done_elt.addEventListener('click', () => { this.name = name_input_elt.value this.elements.title.textContent = this.name - this.secret = secret_input_elt.value + // 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) @@ -460,7 +461,8 @@ class Entry async function addEntry(event) { event.preventDefault() - await store.addEntry(entry_name_elt.value, entry_secret_elt.value) + // Remove any white spaces (can happen for readability) + await store.addEntry(entry_name_elt.value, entry_secret_elt.value.replace(/\s/g, '')) } function start() From f1a1fbe5e991021071e555e0effe0caadf813840 Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 20 Mar 2023 06:27:14 +0900 Subject: [PATCH 05/10] Bump to version 1.3.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index a4e3862..b6072e9 100644 --- a/manifest.json +++ b/manifest.json @@ -20,5 +20,5 @@ "storage", "clipboardWrite" ], - "version": "1.2.0" + "version": "1.3.0" } \ No newline at end of file From 4107bbc09385dee5323fed6921c00c77285cb17b Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 20 Mar 2023 06:38:08 +0900 Subject: [PATCH 06/10] Remove innerHTML use * Bump to version 1.3.1 --- manifest.json | 2 +- popup.html | 2 +- popup.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index b6072e9..f380b49 100644 --- a/manifest.json +++ b/manifest.json @@ -20,5 +20,5 @@ "storage", "clipboardWrite" ], - "version": "1.3.0" + "version": "1.3.1" } \ No newline at end of file diff --git a/popup.html b/popup.html index 7bad604..8ca0023 100644 --- a/popup.html +++ b/popup.html @@ -7,7 +7,7 @@
- +
diff --git a/popup.js b/popup.js index 6caccc4..c8e033f 100644 --- a/popup.js +++ b/popup.js @@ -132,7 +132,7 @@ function _notification() { if(notifications.length !== 0 && notification_timeout === null) { - notification_text_elt.innerHTML = notifications[0] + notification_text_elt.firstChild.data = notifications[0] notification_elt.classList.add('show') notification_timeout = setTimeout(_next_notification, notification_delay) } From 2eaa1753a0dae5e75025557af4dfe977ad10663a Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 20 Mar 2023 06:56:10 +0900 Subject: [PATCH 07/10] Clean wrong password (avoid repeated notification) * Add cursor pointer on notification close icon --- background.js | 1 + manifest.json | 2 +- popup.css | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/background.js b/background.js index 92a2d42..fb93789 100644 --- a/background.js +++ b/background.js @@ -130,6 +130,7 @@ async function restore(storage) } catch(_error) { + state.key = null show_error('Cannot decrypt entries: wrong password') } } diff --git a/manifest.json b/manifest.json index f380b49..7985937 100644 --- a/manifest.json +++ b/manifest.json @@ -20,5 +20,5 @@ "storage", "clipboardWrite" ], - "version": "1.3.1" + "version": "1.3.2" } \ No newline at end of file diff --git a/popup.css b/popup.css index dbb5378..7022918 100644 --- a/popup.css +++ b/popup.css @@ -201,6 +201,7 @@ h2 { #notification img { height: 1em; + cursor: pointer; } #password-container { From c20a6397fc54f9fd767aa56fbdf08823af19b73b Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 4 Mar 2024 14:07:46 +0900 Subject: [PATCH 08/10] Implement import function * Add ESLint rules * Change code style : using semi-columns * Change error notification : using pre + wrap to be able to add newlines * Bump version to 1.4.0 --- .eslintrc.json | 28 +++ .gitignore | 4 +- .npmrc | 1 + background.js | 104 ++++----- icons/export.svg | 2 +- icons/import.svg | 2 +- manifest.json | 2 +- package.json | 6 + popup.css | 30 ++- popup.html | 11 +- popup.js | 579 +++++++++++++++++++++++++---------------------- 11 files changed, 433 insertions(+), 336 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .npmrc create mode 100644 package.json 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 fb93789..8515be2 100644 --- a/background.js +++ b/background.js @@ -4,74 +4,74 @@ let state = { salt: window.crypto.getRandomValues(new Uint8Array(16)), key: null, entries: [] -} +}; -let popup_port = null // communication channel with popup +let popup_port = null; // communication channel with popup function send_state(state) { - popup_port.postMessage({type: 'state', data: state}) + popup_port.postMessage({type: 'state', data: state}); } function show_error(error) { - console.error(error) - popup_port.postMessage({type: 'notification', data: 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() - 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(); + 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) { try { - state.iv = new Uint8Array(b32decode(storage.iv)) - state.salt = new Uint8Array(b32decode(storage.salt)) + state.iv = new Uint8Array(b32decode(storage.iv)); + state.salt = new Uint8Array(b32decode(storage.salt)); } catch(error) { - show_error(error) - return + show_error(error); + return; } } @@ -81,7 +81,7 @@ async function setKey(password) encoder.encode(password), 'PBKDF2', false, - ['deriveKey']) + ['deriveKey']); state.key = await window.crypto.subtle.deriveKey( { name: "PBKDF2", @@ -92,26 +92,26 @@ 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) + send_state(state); } // Decrypt entries saved in local storage async function restore(storage) { - let decoded_secret = null + let decoded_secret = null; try { - decoded_secret = new Uint8Array(b32decode(storage.entries)) + decoded_secret = new Uint8Array(b32decode(storage.entries)); } catch(error) { - show_error(error) - return + show_error(error); + return; } try @@ -125,36 +125,36 @@ async function restore(storage) ); let decoder = new TextDecoder(); - state.entries = JSON.parse(decoder.decode(decrypted)) - send_state(state) + state.entries = JSON.parse(decoder.decode(decrypted)); + send_state(state); } catch(_error) { - state.key = null - show_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) - send_state(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 - show_error(`Wrong message: ${message}`) - }) + show_error(`Wrong message: ${message}`); + }); } 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 7985937..dd45e78 100644 --- a/manifest.json +++ b/manifest.json @@ -20,5 +20,5 @@ "storage", "clipboardWrite" ], - "version": "1.3.2" + "version": "1.4.0" } \ 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 7022918..eeaa0c7 100644 --- a/popup.css +++ b/popup.css @@ -152,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; @@ -189,7 +204,6 @@ h2 { flex-direction: row; justify-content: space-between; background-color: var(--error-bg-color); - font-size: 1.2em; padding: 4px; transform: translateY(-100%); transition: transform 0.3s; @@ -204,6 +218,10 @@ h2 { cursor: pointer; } +#notification-text { + white-space: pre-wrap; +} + #password-container { padding: 1em; } @@ -217,16 +235,6 @@ h2 { margin-right: 8px; } -#store-action { - display: flex; - justify-content: center; -} - -#store-action img { - height: 2em; - cursor: pointer; -} - @keyframes timer0 { from { stroke-dashoffset: 0; diff --git a/popup.html b/popup.html index 8ca0023..d44df0d 100644 --- a/popup.html +++ b/popup.html @@ -7,7 +7,7 @@
- +
 
@@ -38,8 +38,13 @@
- diff --git a/popup.js b/popup.js index c8e033f..7a30f79 100644 --- a/popup.js +++ b/popup.js @@ -1,140 +1,142 @@ -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 notification_elt = document.querySelector('#notification') -const notification_text_elt = document.querySelector('#notification-text') +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' -const notification_delay = 5000 -const notification_repeat = 500 +const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const notification_delay = 5000; +const notification_repeat = 500; -let notifications = [] -let notification_timeout = null -let refresh_timeout = null +let notifications = []; +let notification_timeout = null; +let refresh_timeout = null; function togglePassword(elt) { if(elt.getAttribute('type') === 'password') - elt.setAttribute('type', 'text') + elt.setAttribute('type', 'text'); else - elt.setAttribute('type', 'password') + elt.setAttribute('type', 'password'); } function b32encode(bytes) { - let result = [] - let index = 0 - let lshift = 0 - let carry = 0 - let byte = bytes[index] + 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 + 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] + 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 + 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('') + result.push(b32chars[carry]); + return result.join(''); } 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() + break; + let value = char.charCodeAt(); if(value >= 65 && value <= 90) // char in [A- Z] - value -= 65 + value -= 65; else if(value >= 50 && value <= 55) // char in [2-7] - value -= 24 + value -= 24; else - throw `Incorrect Base32 char '${text[index]}' at index ${index}` + 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; } function show_notification(text) { - notifications.push(text) - _notification() + notifications.push(text); + _notification(); } function show_error(error) { - console.error(error) - show_notification(error) + console.error(error); + show_notification(error); } function close_notification() { - clearTimeout(notification_timeout) - _next_notification() + clearTimeout(notification_timeout); + _next_notification(); } function _next_notification() { - notification_timeout = null - notification_elt.classList.remove('show') - notifications.shift() + notification_timeout = null; + notification_elt.classList.remove('show'); + notifications.shift(); if(notifications.length !== 0) - setTimeout(_notification, notification_repeat) + setTimeout(_notification, notification_repeat); } function _notification() { if(notifications.length !== 0 && notification_timeout === null) { - notification_text_elt.firstChild.data = notifications[0] - notification_elt.classList.add('show') - notification_timeout = setTimeout(_next_notification, notification_delay) + notification_text_elt.firstChild.data = notifications[0]; + notification_elt.classList.add('show'); + notification_timeout = setTimeout(_next_notification, notification_delay); } } @@ -142,160 +144,208 @@ class Store { constructor() { - this.entries = [] - this.iv = null - this.salt = null - this.key = null + 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 + 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) + await store.addEntry(entry.name, entry.secret, false); } - store.save() + store.save(); } async addEntry(name, secret, save=true) { - const entry = await Entry.create(name, secret) + const entry = await Entry.create(name, secret); if(entry === null) - return - entry.insert() - this.entries.push(entry) + return; + entry.insert(); + this.entries.push(entry); if(save) - this.save() + this.save(); } async removeEntry(entry) { - entry.remove() - this.entries = this.entries.filter(test_entry => test_entry !== entry) - this.save() + entry.remove(); + this.entries = this.entries.filter(test_entry => test_entry !== entry); + this.save(); } async save() { if(this.key === null) { - show_error(`Cannot save store without key`) - return + 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}})))) + 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 + const now = Date.now(); + const count_time = now / 30000; + const counter = Math.floor(count_time); + const animation_delay = (count_time - counter) * 30; if(refresh_timeout !== null) - clearTimeout(refresh_timeout) + clearTimeout(refresh_timeout); refresh_timeout = setTimeout( - () => { store.refresh() }, - ((counter + 1) * 30000) - now + 1) + () => { store.refresh(); }, + ((counter + 1) * 30000) - now + 1); for(const entry of this.entries) { - entry.refresh(counter) - entry.refreshTimer(animation_delay) + entry.refresh(counter); + entry.refreshTimer(animation_delay); } } close() { - this.entries.forEach(entry => { entry.remove() }) - this.entries = [] - this.key = null + this.entries.forEach(entry => { entry.remove(); }); + this.entries = []; + this.key = null; } export() { - let export_elt = document.createElement('a') + 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) + 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) + { + show_error('Error reading import file : not a valid json.'); + } + if(!Array.isArray(import_json)) + { + 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) + 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); } } -let store = new Store() +let store = new Store(); class Entry { constructor(name, secret, key) { - this.name = name - this.secret = secret - this.key = key - this.timer_count = 0 + this.name = name; + this.secret = secret; + this.key = key; + this.timer_count = 0; - let new_elt = document.createElement('div') - new_elt.className = 'entry' + let new_elt = document.createElement('div'); + new_elt.className = 'entry'; - let header_elt = document.createElement('div') - header_elt.className = 'header' + 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) + 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) + 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) + 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' + 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) + 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) + 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, @@ -307,47 +357,47 @@ class Entry action: action_elt, edit: edit_elt, delete: delete_elt - } + }; } // static function to construct Entry while being async static async create(name, secret) { - let decoded_secret = '' + let decoded_secret = ''; try { - decoded_secret = new Uint8Array(b32decode(secret)) + decoded_secret = new Uint8Array(b32decode(secret)); } catch(error) { - show_error(error) - return null + 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(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 + 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']); + const new_entry = new Entry(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 = '' + let decoded_secret = ''; try { - decoded_secret = new Uint8Array(b32decode(this.secret)) + decoded_secret = new Uint8Array(b32decode(this.secret)); } catch(error) { - show_error(error) - return + show_error(error); + return; } this.key = await window.crypto.subtle.importKey( - 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']) + 'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']); } async refresh(counter) @@ -357,133 +407,129 @@ class Entry // 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) + 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)) + 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 + const offset = signature[signature.length - 1] & 15; // Masking the highest bit of the byte located at calculated offset - signature[offset] = signature[offset] & 127 + 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) + 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}` + 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}`; } insert() { - entry_list_elt.appendChild(this.elements.main) + entry_list_elt.appendChild(this.elements.main); } remove() { - entry_list_elt.removeChild(this.elements.main) + 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') - this.elements.svg_timer_elt.classList.add('hidden') + [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 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 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) + 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 + 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.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) - }) - 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() - }) + const count_time = Date.now() / 30000; + const counter = Math.floor(count_time); + this.refresh(counter); + this.refreshTimer((count_time - counter) * 30); + }); + 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() - }) + 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') - this.elements.svg_timer_elt.classList.remove('hidden') + [this.elements.title, this.elements.otp, this.elements.edit, this.elements.delete, + this.elements.svg_timer_elt].forEach(elt => { elt.classList.remove('hidden'); } ); } } async function addEntry(event) { - event.preventDefault() + event.preventDefault(); // Remove any white spaces (can happen for readability) - await store.addEntry(entry_name_elt.value, entry_secret_elt.value.replace(/\s/g, '')) + 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() + password_container_elt.classList.add('hidden'); + [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { + 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) + clearTimeout(refresh_timeout); + refresh_timeout = setTimeout(() => { store.refresh(); }, ((Math.floor(now / 30000) + 1) * 30000) - now + 1); } function stop() { - store_action_elt.classList.add('hidden') - entry_add_container_elt.classList.add('hidden') - password_container_elt.classList.remove('hidden') + [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { + elt.classList.add('hidden'); + }); + password_container_elt.classList.remove('hidden'); if(refresh_timeout !== null) - clearTimeout(refresh_timeout) - store.close() + clearTimeout(refresh_timeout); + store.close(); } let background_port = browser.runtime.connect({name:"background"}); @@ -491,34 +537,35 @@ 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() }) - document.querySelector('#notification img').addEventListener('click', close_notification) + background_port.postMessage({command: 'logout', password: master_password_elt.value}); + stop(); + }); + export_elt.addEventListener('click', () => { store.export(); }); + import_elt.addEventListener('click', () => { store.import(); }); + document.querySelector('#notification img').addEventListener('click', close_notification); - background_port.postMessage({command: 'init'}) + 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) + store.loadState(message.data).then(start); } else if(message.type === 'notification') - show_notification(message.data) + show_notification(message.data); else - show_error(`unexpected background message with type ${message.type}`) - }) + show_error(`unexpected background message with type ${message.type}`); + }); } init().catch(error => { - show_error(error) -}) + show_error(error); +}); From b9d8c891207a00e8ebc4f41cc23777f504a5bdd7 Mon Sep 17 00:00:00 2001 From: Corentin Date: Tue, 7 May 2024 10:04:48 +0900 Subject: [PATCH 09/10] Fix import * Add options page to import/export (export also possible from popup) * Change popup import to open options * Add separate store.js file for common code between popup and options * Bump version to 1.5.0 --- manifest.json | 6 +- options.css | 96 ++++++++++ options.html | 24 +++ options.js | 46 +++++ popup.html | 2 +- popup.js | 513 ++------------------------------------------------ store.js | 493 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 679 insertions(+), 501 deletions(-) create mode 100644 options.css create mode 100644 options.html create mode 100644 options.js create mode 100644 store.js diff --git a/manifest.json b/manifest.json index dd45e78..464160e 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.4.0" + "version": "1.5.0" } \ 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/popup.html b/popup.html index d44df0d..2c99ef4 100644 --- a/popup.html +++ b/popup.html @@ -47,6 +47,6 @@
- + \ No newline at end of file diff --git a/popup.js b/popup.js index 7a30f79..11fce02 100644 --- a/popup.js +++ b/popup.js @@ -1,3 +1,5 @@ +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'); @@ -16,491 +18,9 @@ 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'; -const notification_delay = 5000; -const notification_repeat = 500; - -let notifications = []; -let notification_timeout = null; -let refresh_timeout = null; - -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(); - 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; -} - -function show_notification(text) -{ - notifications.push(text); - _notification(); -} - -function show_error(error) -{ - console.error(error); - show_notification(error); -} - -function close_notification() -{ - clearTimeout(notification_timeout); - _next_notification(); -} - -function _next_notification() -{ - notification_timeout = null; - notification_elt.classList.remove('show'); - notifications.shift(); - if(notifications.length !== 0) - setTimeout(_notification, notification_repeat); -} - -function _notification() -{ - if(notifications.length !== 0 && notification_timeout === null) - { - notification_text_elt.firstChild.data = notifications[0]; - notification_elt.classList.add('show'); - notification_timeout = setTimeout(_next_notification, notification_delay); - } -} - -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); - if(entry === null) - return; - 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) - { - 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(refresh_timeout !== null) - clearTimeout(refresh_timeout); - refresh_timeout = setTimeout( - () => { store.refresh(); }, - ((counter + 1) * 30000) - now + 1); - - for(const entry of this.entries) - { - entry.refresh(counter); - entry.refreshTimer(animation_delay); - } - } - - close() - { - this.entries.forEach(entry => { entry.remove(); }); - 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) - { - show_error('Error reading import file : not a valid json.'); - } - if(!Array.isArray(import_json)) - { - 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) - 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); - } -} - -let store = new Store(); - -class Entry -{ - constructor(name, secret, key) - { - 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', () => {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(name, secret) - { - let decoded_secret = ''; - try - { - decoded_secret = new Uint8Array(b32decode(secret)); - } - catch(error) - { - 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(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) - { - 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}`; - } - - insert() - { - entry_list_elt.appendChild(this.elements.main); - } - - remove() - { - entry_list_elt.removeChild(this.elements.main); - } - - 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); - }); - 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'); } ); - } -} +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) { @@ -514,11 +34,7 @@ function start() password_container_elt.classList.add('hidden'); [store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => { 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); + store.refresh(); } function stop() @@ -527,13 +43,9 @@ function stop() 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) => { @@ -549,8 +61,11 @@ async function init() stop(); }); export_elt.addEventListener('click', () => { store.export(); }); - import_elt.addEventListener('click', () => { store.import(); }); - document.querySelector('#notification img').addEventListener('click', close_notification); + 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((message) => { @@ -560,12 +75,12 @@ async function init() store.loadState(message.data).then(start); } else if(message.type === 'notification') - show_notification(message.data); + notification.show_notification(message.data); else - show_error(`unexpected background message with type ${message.type}`); + notification.show_error(`unexpected background message with type ${message.type}`); }); } init().catch(error => { - show_error(error); + notification.show_error(error); }); diff --git a/store.js b/store.js new file mode 100644 index 0000000..cb97eb6 --- /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.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'); } ); + } +} From 73944b22ad22e37b3f0bc20ba7cbd43771e95ecf Mon Sep 17 00:00:00 2001 From: Corentin Date: Tue, 7 May 2024 10:30:15 +0900 Subject: [PATCH 10/10] Fix edit * Bump version to 1.5.1 --- manifest.json | 2 +- popup.js | 4 +--- store.js | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/manifest.json b/manifest.json index 464160e..4f78519 100644 --- a/manifest.json +++ b/manifest.json @@ -24,5 +24,5 @@ "storage", "clipboardWrite" ], - "version": "1.5.0" + "version": "1.5.1" } \ No newline at end of file diff --git a/popup.js b/popup.js index 11fce02..baf23ea 100644 --- a/popup.js +++ b/popup.js @@ -61,9 +61,7 @@ async function init() stop(); }); export_elt.addEventListener('click', () => { store.export(); }); - import_elt.addEventListener('click', () => { - browser.tabs.create({ url: "options.html" }); - }); + import_elt.addEventListener('click', () => { browser.tabs.create({ url: "options.html" }); }); document.querySelector('#notification img').addEventListener( 'click', notification.close_notification.bind(notification)); diff --git a/store.js b/store.js index cb97eb6..ac73f08 100644 --- a/store.js +++ b/store.js @@ -422,7 +422,7 @@ class Entry refreshTimer(animation_delay) { - this.timer_count = 1- this.timer_count; + 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}`; } @@ -469,7 +469,7 @@ class Entry this.refresh(counter); this.refreshTimer((count_time - counter) * 30); }); - this.save(); + this.store.save(); this.elements.main.removeChild(secret_container_elt); this.elements.action.removeChild(done_elt); this.elements.action.removeChild(close_elt);