Add notifications
* Fix entry edit
This commit is contained in:
parent
372ab0e07e
commit
718315d7c4
4 changed files with 185 additions and 40 deletions
|
|
@ -8,6 +8,17 @@ let state = {
|
||||||
|
|
||||||
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_error(error)
|
||||||
|
{
|
||||||
|
console.error(error)
|
||||||
|
popup_port.postMessage({type: 'notification', data: error})
|
||||||
|
}
|
||||||
|
|
||||||
function b32decode(text)
|
function b32decode(text)
|
||||||
{
|
{
|
||||||
const up_text = text.toUpperCase()
|
const up_text = text.toUpperCase()
|
||||||
|
|
@ -19,11 +30,13 @@ function b32decode(text)
|
||||||
const char = up_text[index]
|
const char = up_text[index]
|
||||||
if(char === '=')
|
if(char === '=')
|
||||||
break
|
break
|
||||||
let value = char.charCodeAt() - 65
|
let value = char.charCodeAt()
|
||||||
if(value < 0)
|
if(value >= 65 && value <= 90) // char in [A- Z]
|
||||||
value += 41
|
value -= 65
|
||||||
if(value < 0 || value > 31)
|
else if(value >= 50 && value <= 55) // char in [2-7]
|
||||||
throw `Incorrect Base32 char '${text[index]}' at index ${index}`
|
value -= 24
|
||||||
|
else
|
||||||
|
throw `Incorrect Base32 char '${text[index]}' at index ${index}`
|
||||||
|
|
||||||
if(lshift > 0) // next value is needed to complete the octet
|
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()
|
const storage = await browser.storage.local.get()
|
||||||
if(storage.entries !== undefined && storage.iv !== undefined && storage.salt !== undefined)
|
if(storage.entries !== undefined && storage.iv !== undefined && storage.salt !== undefined)
|
||||||
{
|
{
|
||||||
state.iv = new Uint8Array(b32decode(storage.iv))
|
try
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let encoder = new TextEncoder();
|
let encoder = new TextEncoder();
|
||||||
|
|
@ -76,12 +97,23 @@ async function setKey(password)
|
||||||
if(storage.entries !== undefined)
|
if(storage.entries !== undefined)
|
||||||
restore(storage)
|
restore(storage)
|
||||||
else
|
else
|
||||||
popup_port.postMessage(state)
|
send_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt entries saved in local storage
|
// Decrypt entries saved in local storage
|
||||||
async function restore(storage)
|
async function restore(storage)
|
||||||
{
|
{
|
||||||
|
let decoded_secret = null
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decoded_secret = new Uint8Array(b32decode(storage.entries))
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
show_error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
let decrypted = await window.crypto.subtle.decrypt(
|
let decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
|
@ -89,17 +121,16 @@ async function restore(storage)
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
iv: state.iv
|
iv: state.iv
|
||||||
},
|
},
|
||||||
state.key,
|
state.key, decoded_secret
|
||||||
new Uint8Array(b32decode(storage.entries))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let decoder = new TextDecoder();
|
let decoder = new TextDecoder();
|
||||||
state.entries = JSON.parse(decoder.decode(decrypted))
|
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(message.command === 'init')
|
||||||
{
|
{
|
||||||
if(state.key === null)
|
if(state.key === null)
|
||||||
popup_port.postMessage(state)
|
send_state(state)
|
||||||
else
|
else
|
||||||
browser.storage.local.get().then((storage) => {
|
browser.storage.local.get().then((storage) => { restore(storage) })
|
||||||
restore(storage)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
else if(message.command === 'key')
|
else if(message.command === 'key')
|
||||||
setKey(message.password)
|
setKey(message.password)
|
||||||
|
|
@ -123,8 +152,8 @@ function connected(port) {
|
||||||
state.entries = []
|
state.entries = []
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
console.error(`Wrong message: ${message}`)
|
show_error(`Wrong message: ${message}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.runtime.onConnect.addListener(connected);
|
browser.runtime.onConnect.addListener(connected);
|
||||||
32
popup.css
32
popup.css
|
|
@ -5,6 +5,7 @@
|
||||||
--highlight-bg-color: hsl(213, 10%, 28%);
|
--highlight-bg-color: hsl(213, 10%, 28%);
|
||||||
--fg-color: #ccc;
|
--fg-color: #ccc;
|
||||||
--border-color: #888;
|
--border-color: #888;
|
||||||
|
--error-bg-color: hsl(0, 38%, 30%);
|
||||||
--img-filter: ;
|
--img-filter: ;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,16 +17,23 @@
|
||||||
--highlight-bg-color: #ccc;
|
--highlight-bg-color: #ccc;
|
||||||
--fg-color: #666;
|
--fg-color: #666;
|
||||||
--border-color: #888;
|
--border-color: #888;
|
||||||
|
--error-bg-color: hsl(0, 38%, 70%);
|
||||||
--img-filter: invert(1);
|
--img-filter: invert(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*
|
||||||
|
{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--fg-color);
|
color: var(--fg-color);
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
|
|
@ -171,6 +179,30 @@ h2 {
|
||||||
cursor: pointer;
|
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 {
|
#password-container {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
<link rel="stylesheet" href="popup.css"/>
|
<link rel="stylesheet" href="popup.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="notification">
|
||||||
|
<span id="notification-text"></span>
|
||||||
|
<img src="/icons/close.svg"/>
|
||||||
|
</div>
|
||||||
<div id="password-container">
|
<div id="password-container">
|
||||||
<form id="password-form">
|
<form id="password-form">
|
||||||
<label for="master-password">Password</label>
|
<label for="master-password">Password</label>
|
||||||
|
|
|
||||||
120
popup.js
120
popup.js
|
|
@ -11,8 +11,16 @@ const entry_name_elt = document.querySelector('#entry-name')
|
||||||
const entry_secret_elt = document.querySelector('#entry-secret')
|
const entry_secret_elt = document.querySelector('#entry-secret')
|
||||||
const entry_visibility_elt = document.querySelector('#entry-visibility')
|
const entry_visibility_elt = document.querySelector('#entry-visibility')
|
||||||
const entry_list_elt = document.querySelector('#entry-list')
|
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 b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||||
|
const notification_delay = 5000
|
||||||
|
const notification_repeat = 500
|
||||||
|
|
||||||
|
let notifications = []
|
||||||
|
let notification_timeout = null
|
||||||
|
let refresh_timeout = null
|
||||||
|
|
||||||
function togglePassword(elt)
|
function togglePassword(elt)
|
||||||
{
|
{
|
||||||
|
|
@ -66,10 +74,12 @@ function b32decode(text)
|
||||||
const char = up_text[index]
|
const char = up_text[index]
|
||||||
if(char === '=')
|
if(char === '=')
|
||||||
break
|
break
|
||||||
let value = char.charCodeAt() - 65
|
let value = char.charCodeAt()
|
||||||
if(value < 0)
|
if(value >= 65 && value <= 90) // char in [A- Z]
|
||||||
value += 41
|
value -= 65
|
||||||
if(value < 0 || value > 31)
|
else if(value >= 50 && value <= 55) // char in [2-7]
|
||||||
|
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
|
if(lshift > 0) // next value is needed to complete the octet
|
||||||
|
|
@ -91,7 +101,42 @@ function b32decode(text)
|
||||||
return result
|
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
|
class Store
|
||||||
{
|
{
|
||||||
|
|
@ -118,6 +163,8 @@ class Store
|
||||||
async addEntry(name, secret, save=true)
|
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()
|
entry.insert()
|
||||||
this.entries.push(entry)
|
this.entries.push(entry)
|
||||||
if(save)
|
if(save)
|
||||||
|
|
@ -134,7 +181,10 @@ class Store
|
||||||
async save()
|
async save()
|
||||||
{
|
{
|
||||||
if(this.key === null)
|
if(this.key === null)
|
||||||
throw `Cannot save store without key`
|
{
|
||||||
|
show_error(`Cannot save store without key`)
|
||||||
|
return
|
||||||
|
}
|
||||||
let encoder = new TextEncoder();
|
let encoder = new TextEncoder();
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
{name: 'AES-GCM', iv: this.iv},
|
{name: 'AES-GCM', iv: this.iv},
|
||||||
|
|
@ -252,6 +302,7 @@ class Entry
|
||||||
header: header_elt,
|
header: header_elt,
|
||||||
title: title_elt,
|
title: title_elt,
|
||||||
otp: otp_elt,
|
otp: otp_elt,
|
||||||
|
svg_timer_elt: svg_timer_elt,
|
||||||
timer_circle: timer_circle_elt,
|
timer_circle: timer_circle_elt,
|
||||||
action: action_elt,
|
action: action_elt,
|
||||||
edit: edit_elt,
|
edit: edit_elt,
|
||||||
|
|
@ -262,8 +313,18 @@ class Entry
|
||||||
// static function to construct Entry while being async
|
// static function to construct Entry while being async
|
||||||
static async create(name, secret)
|
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(
|
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 new_entry = new Entry(name, secret, key)
|
||||||
const count_time = Date.now() / 30000
|
const count_time = Date.now() / 30000
|
||||||
const counter = Math.floor(count_time)
|
const counter = Math.floor(count_time)
|
||||||
|
|
@ -274,8 +335,19 @@ class Entry
|
||||||
|
|
||||||
async regenerateKey()
|
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(
|
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)
|
async refresh(counter)
|
||||||
|
|
@ -321,6 +393,7 @@ class Entry
|
||||||
this.elements.otp.classList.add('hidden')
|
this.elements.otp.classList.add('hidden')
|
||||||
this.elements.edit.classList.add('hidden')
|
this.elements.edit.classList.add('hidden')
|
||||||
this.elements.delete.classList.add('hidden')
|
this.elements.delete.classList.add('hidden')
|
||||||
|
this.elements.svg_timer_elt.classList.add('hidden')
|
||||||
|
|
||||||
let name_input_elt = document.createElement('input')
|
let name_input_elt = document.createElement('input')
|
||||||
name_input_elt.value = this.name
|
name_input_elt.value = this.name
|
||||||
|
|
@ -341,7 +414,7 @@ class Entry
|
||||||
|
|
||||||
let done_elt = document.createElement('img')
|
let done_elt = document.createElement('img')
|
||||||
done_elt.src = "/icons/done.svg"
|
done_elt.src = "/icons/done.svg"
|
||||||
done_elt = 'Save'
|
done_elt.title = 'Save'
|
||||||
this.elements.action.appendChild(done_elt)
|
this.elements.action.appendChild(done_elt)
|
||||||
let close_elt = document.createElement('img')
|
let close_elt = document.createElement('img')
|
||||||
close_elt.src = "/icons/close.svg"
|
close_elt.src = "/icons/close.svg"
|
||||||
|
|
@ -380,6 +453,7 @@ class Entry
|
||||||
this.elements.otp.classList.remove('hidden')
|
this.elements.otp.classList.remove('hidden')
|
||||||
this.elements.edit.classList.remove('hidden')
|
this.elements.edit.classList.remove('hidden')
|
||||||
this.elements.delete.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')
|
entry_add_container_elt.classList.remove('hidden')
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if(refresh_timeout !== null)
|
if(refresh_timeout !== null)
|
||||||
clearTimeout(refresh_timeout)
|
clearTimeout(refresh_timeout)
|
||||||
refresh_timeout = setTimeout(
|
refresh_timeout = setTimeout(() => { store.refresh() }, ((Math.floor(now / 30000) + 1) * 30000) - now + 1)
|
||||||
() => {
|
|
||||||
store.refresh()
|
|
||||||
},
|
|
||||||
((Math.floor(now / 30000) + 1) * 30000) - now + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop()
|
function stop()
|
||||||
|
|
@ -410,7 +480,7 @@ function stop()
|
||||||
entry_add_container_elt.classList.add('hidden')
|
entry_add_container_elt.classList.add('hidden')
|
||||||
password_container_elt.classList.remove('hidden')
|
password_container_elt.classList.remove('hidden')
|
||||||
if(refresh_timeout !== null)
|
if(refresh_timeout !== null)
|
||||||
clearTimeout(refresh_timeout)
|
clearTimeout(refresh_timeout)
|
||||||
store.close()
|
store.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,12 +501,22 @@ async function init()
|
||||||
stop()
|
stop()
|
||||||
})
|
})
|
||||||
export_elt.addEventListener('click', () => { store.export() })
|
export_elt.addEventListener('click', () => { store.export() })
|
||||||
|
document.querySelector('#notification img').addEventListener('click', close_notification)
|
||||||
|
|
||||||
background_port.postMessage({command: 'init'})
|
background_port.postMessage({command: 'init'})
|
||||||
background_port.onMessage.addListener((state) => {
|
background_port.onMessage.addListener((message) => {
|
||||||
if(state.key !== null)
|
if(message.type === 'state')
|
||||||
store.loadState(state).then(() => start())
|
{
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue