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'); } ); + } +}