Add notifications

* Fix entry edit
This commit is contained in:
Corentin 2023-03-20 06:12:08 +09:00
commit 718315d7c4
4 changed files with 185 additions and 40 deletions

View file

@ -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,10 +30,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
@ -49,10 +62,18 @@ 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)
{
try
{ {
state.iv = new Uint8Array(b32decode(storage.iv)) state.iv = new Uint8Array(b32decode(storage.iv))
state.salt = new Uint8Array(b32decode(storage.salt)) state.salt = new Uint8Array(b32decode(storage.salt))
} }
catch(error)
{
show_error(error)
return
}
}
let encoder = new TextEncoder(); let encoder = new TextEncoder();
const key_material = await window.crypto.subtle.importKey( const key_material = await window.crypto.subtle.importKey(
@ -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,7 +152,7 @@ function connected(port) {
state.entries = [] state.entries = []
} }
else else
console.error(`Wrong message: ${message}`) show_error(`Wrong message: ${message}`)
}) })
} }

View file

@ -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;
} }

View file

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

116
popup.js
View file

@ -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')
} }
} }
@ -397,11 +471,7 @@ function start()
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()
@ -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)
})