Compare commits
10 commits
d20ee9ab7b
...
73944b22ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 73944b22ad | |||
| b9d8c89120 | |||
|
|
c20a6397fc | ||
|
|
2eaa1753a0 | ||
|
|
4107bbc093 | ||
|
|
f1a1fbe5e9 | ||
|
|
d2baffb5d4 | ||
|
|
718315d7c4 | ||
|
|
372ab0e07e | ||
|
|
d852740998 |
15 changed files with 947 additions and 450 deletions
28
.eslintrc.json
Normal file
28
.eslintrc.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@
|
||||||
*.png
|
*.png
|
||||||
|
|
||||||
.web-extension-id
|
.web-extension-id
|
||||||
|
|
||||||
|
node_modules
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package-lock = false
|
||||||
122
background.js
122
background.js
|
|
@ -4,54 +4,75 @@ let state = {
|
||||||
salt: window.crypto.getRandomValues(new Uint8Array(16)),
|
salt: window.crypto.getRandomValues(new Uint8Array(16)),
|
||||||
key: null,
|
key: null,
|
||||||
entries: []
|
entries: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let popup_port = null; // communication channel with popup
|
||||||
|
|
||||||
|
function send_state(state)
|
||||||
|
{
|
||||||
|
popup_port.postMessage({type: 'state', data: state});
|
||||||
}
|
}
|
||||||
|
|
||||||
let popup_port = null // communication channel with popup
|
function show_error(error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
popup_port.postMessage({type: 'notification', data: error});
|
||||||
|
}
|
||||||
|
|
||||||
function b32decode(text)
|
function b32decode(text)
|
||||||
{
|
{
|
||||||
const up_text = text.toUpperCase()
|
const up_text = text.toUpperCase();
|
||||||
let result = []
|
let result = [];
|
||||||
let lshift = 3
|
let lshift = 3;
|
||||||
let carry = 0
|
let carry = 0;
|
||||||
for(const index in up_text)
|
for(const index in up_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
|
||||||
{
|
{
|
||||||
carry += value << lshift
|
carry += value << lshift;
|
||||||
lshift -= 5
|
lshift -= 5;
|
||||||
if(lshift === 0) // last value aligned with octet
|
if(lshift === 0) // last value aligned with octet
|
||||||
carry = 0
|
carry = 0;
|
||||||
}
|
}
|
||||||
else // getting the last octet' bits
|
else // getting the last octet' bits
|
||||||
{
|
{
|
||||||
result.push(carry + (value >>> (-lshift)))
|
result.push(carry + (value >>> (-lshift)));
|
||||||
carry = (value << (lshift + 8)) & 248 // if lshit is 0 this will always be 0
|
carry = (value << (lshift + 8)) & 248; // if lshit is 0 this will always be 0
|
||||||
lshift += 3
|
lshift += 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(carry !== 0)
|
if(carry !== 0)
|
||||||
result.push(carry)
|
result.push(carry);
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create encryption key from password
|
// Create encryption key from password
|
||||||
async function setKey(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)
|
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();
|
||||||
|
|
@ -60,7 +81,7 @@ async function setKey(password)
|
||||||
encoder.encode(password),
|
encoder.encode(password),
|
||||||
'PBKDF2',
|
'PBKDF2',
|
||||||
false,
|
false,
|
||||||
['deriveKey'])
|
['deriveKey']);
|
||||||
state.key = await window.crypto.subtle.deriveKey(
|
state.key = await window.crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: "PBKDF2",
|
name: "PBKDF2",
|
||||||
|
|
@ -71,17 +92,28 @@ async function setKey(password)
|
||||||
key_material,
|
key_material,
|
||||||
{ "name": "AES-GCM", "length": 256},
|
{ "name": "AES-GCM", "length": 256},
|
||||||
true,
|
true,
|
||||||
["encrypt", "decrypt"])
|
["encrypt", "decrypt"]);
|
||||||
// If entries are set in the state we automatically restore them
|
// If entries are set in the state we automatically restore them
|
||||||
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,42 +121,40 @@ 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')
|
state.key = null;
|
||||||
|
show_error('Cannot decrypt entries: wrong password');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connected(port) {
|
function connected(port) {
|
||||||
popup_port = port
|
popup_port = port;
|
||||||
popup_port.onMessage.addListener((message) => {
|
popup_port.onMessage.addListener((message) => {
|
||||||
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);
|
||||||
else if(message.command === 'logout')
|
else if(message.command === 'logout')
|
||||||
{
|
{
|
||||||
state.key = null
|
state.key = null;
|
||||||
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);
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48" style="stroke:#ccc;fill:#ccc;">
|
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48" style="stroke:#ccc;fill:#ccc;">
|
||||||
<path d="M11 40q-1.2 0-2.1-.9Q8 38.2 8 37v-7.15h3V37h26v-7.15h3V37q0 1.2-.9 2.1-.9.9-2.1.9Zm13-7.65-9.65-9.65 2.15-2.15 6 6V8h3v18.55l6-6 2.15 2.15Z"/>
|
<path d="M11 40q-1.2 0-2.1-.9Q8 38.2 8 37v-7.15h3V37h26v-7.15h3V37q0 1.2-.9 2.1-.9.9-2.1.9Zm11.5-7.65V13.8l-6 6-2.15-2.15L24 8l9.65 9.65-2.15 2.15-6-6v18.55Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 283 B |
|
|
@ -1,3 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48" style="stroke:#ccc;fill:#ccc;">
|
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48" style="stroke:#ccc;fill:#ccc;">
|
||||||
<path d="M11 40q-1.2 0-2.1-.9Q8 38.2 8 37v-7.15h3V37h26v-7.15h3V37q0 1.2-.9 2.1-.9.9-2.1.9Zm11.5-7.65V13.8l-6 6-2.15-2.15L24 8l9.65 9.65-2.15 2.15-6-6v18.55Z"/>
|
<path d="M11 40q-1.2 0-2.1-.9Q8 38.2 8 37v-7.15h3V37h26v-7.15h3V37q0 1.2-.9 2.1-.9.9-2.1.9Zm13-7.65-9.65-9.65 2.15-2.15 6 6V8h3v18.55l6-6 2.15 2.15Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 274 B |
|
|
@ -16,9 +16,13 @@
|
||||||
},
|
},
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "uOTP",
|
"name": "uOTP",
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"clipboardWrite"
|
"clipboardWrite"
|
||||||
],
|
],
|
||||||
"version": "1.1.1"
|
"version": "1.5.1"
|
||||||
}
|
}
|
||||||
96
options.css
Normal file
96
options.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
24
options.html
Normal file
24
options.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="stylesheet" href="options.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1><img src="/icons/uotp.svg" />uOTP Options</h1>
|
||||||
|
<div class="action">
|
||||||
|
<button id="export" title="Export">
|
||||||
|
<img src="/icons/export.svg" />Export
|
||||||
|
</button>
|
||||||
|
<button id="import" title="Import">
|
||||||
|
<img src="/icons/import.svg" />Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="notification">
|
||||||
|
<pre id="notification-text"> </pre>
|
||||||
|
<img src="/icons/close.svg"/>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
options.js
Normal file
46
options.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
6
package.json
Normal file
6
package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@stylistic/eslint-plugin-js": "^1.6.3",
|
||||||
|
"eslint": "^8.57.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
popup.css
98
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 {
|
||||||
|
|
@ -77,17 +85,40 @@ h2 {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry .otp-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.entry .otp {
|
.entry .otp {
|
||||||
background-color: var(--light-bg-color);
|
background-color: var(--light-bg-color);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry .otp:hover {
|
.entry .otp:hover {
|
||||||
background-color: var(--highlight-bg-color);
|
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 {
|
#entry-list .entry {
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +152,21 @@ h2 {
|
||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#action > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#action img {
|
||||||
|
height: 2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
#entry-add-container {
|
#entry-add-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
|
@ -148,6 +194,34 @@ 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);
|
||||||
|
padding: 4px;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification img {
|
||||||
|
height: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
#password-container {
|
#password-container {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
@ -161,12 +235,20 @@ h2 {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#store-action {
|
@keyframes timer0 {
|
||||||
display: flex;
|
from {
|
||||||
justify-content: center;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 6.28318px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#store-action img {
|
@keyframes timer1 {
|
||||||
height: 2em;
|
from {
|
||||||
cursor: pointer;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 6.28318px;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
popup.html
21
popup.html
|
|
@ -6,11 +6,15 @@
|
||||||
<link rel="stylesheet" href="popup.css"/>
|
<link rel="stylesheet" href="popup.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="notification">
|
||||||
|
<pre id="notification-text"> </pre>
|
||||||
|
<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>
|
||||||
<div class="password-container">
|
<div class="password-container">
|
||||||
<input type="password" id="master-password" autofocus />
|
<input type="password" id="master-password" autofocus autocomplete="off" />
|
||||||
<img id="master-visibility" class="icon visibility" src="/icons/visibility.svg" />
|
<img id="master-visibility" class="icon visibility" src="/icons/visibility.svg" />
|
||||||
</div>
|
</div>
|
||||||
<button>Open</button>
|
<button>Open</button>
|
||||||
|
|
@ -21,12 +25,12 @@
|
||||||
<form id="entry-form">
|
<form id="entry-form">
|
||||||
<div>
|
<div>
|
||||||
<label for="entry-name">Name</label>
|
<label for="entry-name">Name</label>
|
||||||
<input type="text" id="entry-name" required />
|
<input type="text" id="entry-name" autocomplete="off" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="entry-secret">Secret</label>
|
<label for="entry-secret">Secret</label>
|
||||||
<div class="password-container">
|
<div class="password-container">
|
||||||
<input type="password" id="entry-secret" required />
|
<input type="password" id="entry-secret" autocomplete="off" required />
|
||||||
<img id="entry-visibility" class="icon visibility" src="/icons/visibility.svg" />
|
<img id="entry-visibility" class="icon visibility" src="/icons/visibility.svg" />
|
||||||
</div>
|
</div>
|
||||||
<button><img class="icon" src="/icons/add.svg" title="Add"></button>
|
<button><img class="icon" src="/icons/add.svg" title="Add"></button>
|
||||||
|
|
@ -34,10 +38,15 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="entry-list">
|
<div id="entry-list">
|
||||||
<div id="store-action" class="hidden">
|
<div id="action">
|
||||||
<img id="export" src="/icons/export.svg" title="Export" />
|
<div id="store-action" class="hidden">
|
||||||
|
<img id="export" src="/icons/export.svg" title="Export" />
|
||||||
|
</div>
|
||||||
|
<div id="import-action" class="hidden">
|
||||||
|
<img id="import" src="/icons/import.svg" title="Import" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/popup.js"></script>
|
<script type="module" src="/popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
448
popup.js
448
popup.js
|
|
@ -1,408 +1,84 @@
|
||||||
const password_container_elt = document.querySelector('#password-container')
|
import { togglePassword, NotificationCenter, Store } from './store.js';
|
||||||
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 b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
const password_container_elt = document.querySelector('#password-container');
|
||||||
|
const password_form_elt = document.querySelector('#password-form');
|
||||||
|
const master_password_elt = document.querySelector('#master-password');
|
||||||
|
const master_visibility_elt = document.querySelector('#master-visibility');
|
||||||
|
const entry_add_container_elt = document.querySelector('#entry-add-container');
|
||||||
|
const logout_elt = document.querySelector('#logout');
|
||||||
|
const store_action_elt = document.querySelector('#store-action');
|
||||||
|
const export_elt = document.querySelector('#export');
|
||||||
|
const import_action_elt = document.querySelector('#import-action');
|
||||||
|
const import_elt = document.querySelector('#import');
|
||||||
|
const entry_form_elt = document.querySelector('#entry-form');
|
||||||
|
const entry_name_elt = document.querySelector('#entry-name');
|
||||||
|
const entry_secret_elt = document.querySelector('#entry-secret');
|
||||||
|
const entry_visibility_elt = document.querySelector('#entry-visibility');
|
||||||
|
const entry_list_elt = document.querySelector('#entry-list');
|
||||||
|
const notification_elt = document.querySelector('#notification');
|
||||||
|
const notification_text_elt = document.querySelector('#notification-text');
|
||||||
|
|
||||||
function togglePassword(elt)
|
let notification = new NotificationCenter(notification_elt, notification_text_elt);
|
||||||
{
|
let store = new Store(notification, entry_list_elt);
|
||||||
if(elt.getAttribute('type') === 'password')
|
let background_port = browser.runtime.connect({name:"background"});
|
||||||
elt.setAttribute('type', 'text')
|
|
||||||
else
|
|
||||||
elt.setAttribute('type', 'password')
|
|
||||||
}
|
|
||||||
|
|
||||||
function b32encode(bytes)
|
|
||||||
{
|
|
||||||
let result = []
|
|
||||||
let index = 0
|
|
||||||
let lshift = 0
|
|
||||||
let carry = 0
|
|
||||||
let byte = bytes[index]
|
|
||||||
while(index < bytes.length)
|
|
||||||
{
|
|
||||||
if(lshift < 0) // need to right shift (should have a carry)
|
|
||||||
{
|
|
||||||
result.push(b32chars[carry + (byte >>> (-lshift + 3))])
|
|
||||||
carry = 0
|
|
||||||
lshift += 5
|
|
||||||
}
|
|
||||||
else if(lshift > 3) // not enough bits to convert (save carry, next will be right shift)
|
|
||||||
{
|
|
||||||
carry = ((byte << lshift) & 248) >> 3
|
|
||||||
lshift -= 8
|
|
||||||
index += 1
|
|
||||||
byte = bytes[index]
|
|
||||||
}
|
|
||||||
else // enough bits to convert
|
|
||||||
{
|
|
||||||
result.push(b32chars[((byte << lshift) & 248) >> 3])
|
|
||||||
lshift += 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(carry !== 0) // if carry left, add has last char
|
|
||||||
result.push(b32chars[carry])
|
|
||||||
return result.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function b32decode(text)
|
|
||||||
{
|
|
||||||
const up_text = text.toUpperCase()
|
|
||||||
let result = []
|
|
||||||
let lshift = 3
|
|
||||||
let carry = 0
|
|
||||||
for(const index in up_text)
|
|
||||||
{
|
|
||||||
const char = up_text[index]
|
|
||||||
if(char === '=')
|
|
||||||
break
|
|
||||||
let value = char.charCodeAt() - 65
|
|
||||||
if(value < 0)
|
|
||||||
value += 41
|
|
||||||
if(value < 0 || value > 31)
|
|
||||||
throw `Incorrect Base32 char '${text[index]}' at index ${index}`
|
|
||||||
|
|
||||||
if(lshift > 0) // next value is needed to complete the octet
|
|
||||||
{
|
|
||||||
carry += value << lshift
|
|
||||||
lshift -= 5
|
|
||||||
if(lshift === 0) // last value aligned with octet
|
|
||||||
carry = 0
|
|
||||||
}
|
|
||||||
else // getting the last octet' bits
|
|
||||||
{
|
|
||||||
result.push(carry + (value >>> (-lshift)))
|
|
||||||
carry = (value << (lshift + 8)) & 248 // if lshit is 0 this will always be 0
|
|
||||||
lshift += 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(carry !== 0)
|
|
||||||
result.push(carry)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
let refresh_timeout = null
|
|
||||||
|
|
||||||
class Store
|
|
||||||
{
|
|
||||||
constructor()
|
|
||||||
{
|
|
||||||
this.entries = []
|
|
||||||
this.iv = null
|
|
||||||
this.salt = null
|
|
||||||
this.key = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadState(state)
|
|
||||||
{
|
|
||||||
this.iv = state.iv
|
|
||||||
this.salt = state.salt
|
|
||||||
this.key = state.key
|
|
||||||
for(const entry of state.entries)
|
|
||||||
{
|
|
||||||
await store.addEntry(entry.name, entry.secret, false)
|
|
||||||
}
|
|
||||||
store.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
async addEntry(name, secret, save=true)
|
|
||||||
{
|
|
||||||
const entry = await Entry.create(name, secret)
|
|
||||||
entry.insert()
|
|
||||||
this.entries.push(entry)
|
|
||||||
if(save)
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeEntry(entry)
|
|
||||||
{
|
|
||||||
entry.remove()
|
|
||||||
this.entries = this.entries.filter(test_entry => test_entry !== entry)
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
async save()
|
|
||||||
{
|
|
||||||
if(this.key === null)
|
|
||||||
throw `Cannot save store without key`
|
|
||||||
let encoder = new TextEncoder();
|
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
|
||||||
{name: 'AES-GCM', iv: this.iv},
|
|
||||||
this.key,
|
|
||||||
encoder.encode(JSON.stringify(
|
|
||||||
this.entries.map(entry => {return {name: entry.name, secret: entry.secret}}))))
|
|
||||||
await browser.storage.local.set({
|
|
||||||
iv: b32encode(this.iv),
|
|
||||||
salt: b32encode(this.salt),
|
|
||||||
entries: b32encode(new Uint8Array(encrypted))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
{
|
|
||||||
const now = Date.now()
|
|
||||||
if(refresh_timeout !== null)
|
|
||||||
clearTimeout(refresh_timeout)
|
|
||||||
refresh_timeout = setTimeout(
|
|
||||||
() => { store.refresh() },
|
|
||||||
((Math.floor(now / 30000) + 1) * 30000) - now + 1)
|
|
||||||
|
|
||||||
const counter = Math.floor(now / 30000)
|
|
||||||
for(const entry of this.entries)
|
|
||||||
{
|
|
||||||
entry.refresh(counter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close()
|
|
||||||
{
|
|
||||||
this.entries.forEach(entry => { entry.remove() })
|
|
||||||
this.entries = []
|
|
||||||
this.key = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export()
|
|
||||||
{
|
|
||||||
let export_elt = document.createElement('a')
|
|
||||||
export_elt.setAttribute('href', 'data:application/json,' + JSON.stringify(
|
|
||||||
this.entries.map(entry => {return {name: entry.name, secret: entry.secret}})))
|
|
||||||
export_elt.setAttribute('download', 'uotp_export.json')
|
|
||||||
export_elt.style.display = 'none'
|
|
||||||
document.body.appendChild(export_elt)
|
|
||||||
export_elt.click()
|
|
||||||
document.body.removeChild(export_elt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let store = new Store()
|
|
||||||
|
|
||||||
class Entry
|
|
||||||
{
|
|
||||||
constructor(name, secret, key)
|
|
||||||
{
|
|
||||||
this.name = name
|
|
||||||
this.secret = secret
|
|
||||||
this.key = key
|
|
||||||
|
|
||||||
let new_elt = document.createElement('div')
|
|
||||||
new_elt.className = 'entry'
|
|
||||||
|
|
||||||
let header_elt = document.createElement('div')
|
|
||||||
header_elt.className = 'header'
|
|
||||||
// Entry title
|
|
||||||
let title_elt = document.createElement('h2')
|
|
||||||
title_elt.textContent = name
|
|
||||||
header_elt.appendChild(title_elt)
|
|
||||||
|
|
||||||
// Entry actions
|
|
||||||
let action_elt = document.createElement('div')
|
|
||||||
action_elt.className = "action"
|
|
||||||
let edit_elt = document.createElement('img')
|
|
||||||
edit_elt.src = "/icons/edit.svg"
|
|
||||||
edit_elt.title = 'Edit'
|
|
||||||
edit_elt.addEventListener('click', () => {this.openEdit()})
|
|
||||||
action_elt.appendChild(edit_elt)
|
|
||||||
let delete_elt = document.createElement('img')
|
|
||||||
delete_elt.src = "/icons/delete.svg"
|
|
||||||
delete_elt.title = 'Delete'
|
|
||||||
delete_elt.addEventListener('click', () => {store.removeEntry(this)})
|
|
||||||
action_elt.appendChild(delete_elt)
|
|
||||||
header_elt.appendChild(action_elt)
|
|
||||||
|
|
||||||
new_elt.appendChild(header_elt)
|
|
||||||
|
|
||||||
// Entry OTP
|
|
||||||
let otp_elt = document.createElement('div')
|
|
||||||
otp_elt.className = 'otp'
|
|
||||||
otp_elt.addEventListener('click', () => {
|
|
||||||
navigator.clipboard.writeText(otp_elt.textContent)
|
|
||||||
})
|
|
||||||
new_elt.appendChild(otp_elt)
|
|
||||||
|
|
||||||
this.elements = {
|
|
||||||
main: new_elt,
|
|
||||||
header: header_elt,
|
|
||||||
title: title_elt,
|
|
||||||
otp: otp_elt,
|
|
||||||
action: action_elt,
|
|
||||||
edit: edit_elt,
|
|
||||||
delete: delete_elt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// static function to construct Entry while being async
|
|
||||||
static async create(name, secret)
|
|
||||||
{
|
|
||||||
const key = await window.crypto.subtle.importKey(
|
|
||||||
'raw', new Uint8Array(b32decode(secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign'])
|
|
||||||
const new_entry = new Entry(name, secret, key)
|
|
||||||
await new_entry.refresh(Math.floor(Date.now() / 30000))
|
|
||||||
return new_entry
|
|
||||||
}
|
|
||||||
|
|
||||||
async regenerateKey()
|
|
||||||
{
|
|
||||||
this.key = await window.crypto.subtle.importKey(
|
|
||||||
'raw', new Uint8Array(b32decode(this.secret)), {name: 'HMAC', hash: 'SHA-1'}, false, ['sign'])
|
|
||||||
}
|
|
||||||
|
|
||||||
async refresh(counter)
|
|
||||||
{
|
|
||||||
// TOTP algorithm is here
|
|
||||||
|
|
||||||
// Set timestamp as byte array for hmac hash (4 bytes is enough)
|
|
||||||
const buffer = new ArrayBuffer(8); // 8 byte buffer needed for HMAC (initialized with 0)
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
view.setUint32(4, counter)
|
|
||||||
// HMAC returns am ArrayBuffer wich is unusable as is (no type), using Uint8Array is needed
|
|
||||||
const signature = new Uint8Array(await window.crypto.subtle.sign("hmac", this.key, buffer))
|
|
||||||
// Getting the offset from the last byte with its highest bit masked to 0
|
|
||||||
const offset = signature[signature.length - 1] & 15
|
|
||||||
// Masking the highest bit of the byte located at calculated offset
|
|
||||||
signature[offset] = signature[offset] & 127
|
|
||||||
// Reading 4 bytes from the hash from the offset (highest byte was masked) and getting the last
|
|
||||||
// 6 digits from its Uint32 representation
|
|
||||||
this.elements.otp.textContent = new DataView(
|
|
||||||
signature.slice(offset, offset + 4).buffer).getUint32().toString().slice(-6)
|
|
||||||
}
|
|
||||||
|
|
||||||
insert()
|
|
||||||
{
|
|
||||||
entry_list_elt.appendChild(this.elements.main)
|
|
||||||
}
|
|
||||||
|
|
||||||
remove()
|
|
||||||
{
|
|
||||||
entry_list_elt.removeChild(this.elements.main)
|
|
||||||
}
|
|
||||||
|
|
||||||
openEdit()
|
|
||||||
{
|
|
||||||
this.elements.title.classList.add('hidden')
|
|
||||||
this.elements.otp.classList.add('hidden')
|
|
||||||
this.elements.edit.classList.add('hidden')
|
|
||||||
this.elements.delete.classList.add('hidden')
|
|
||||||
|
|
||||||
let name_input_elt = document.createElement('input')
|
|
||||||
name_input_elt.value = this.name
|
|
||||||
this.elements.header.insertBefore(name_input_elt, this.elements.action)
|
|
||||||
|
|
||||||
let secret_container_elt = document.createElement('div')
|
|
||||||
secret_container_elt.className = 'password-container'
|
|
||||||
let secret_input_elt = document.createElement('input')
|
|
||||||
secret_input_elt.setAttribute('type', 'password')
|
|
||||||
secret_input_elt.value = this.secret
|
|
||||||
secret_container_elt.appendChild(secret_input_elt)
|
|
||||||
let secret_visibility_elt = document.createElement('img')
|
|
||||||
secret_visibility_elt.className = 'icon visibility'
|
|
||||||
secret_visibility_elt.src = '/icons/visibility.svg'
|
|
||||||
secret_container_elt.appendChild(secret_visibility_elt)
|
|
||||||
secret_visibility_elt.addEventListener('click', () => {togglePassword(secret_input_elt)})
|
|
||||||
this.elements.main.appendChild(secret_container_elt)
|
|
||||||
|
|
||||||
let done_elt = document.createElement('img')
|
|
||||||
done_elt.src = "/icons/done.svg"
|
|
||||||
done_elt = 'Save'
|
|
||||||
this.elements.action.appendChild(done_elt)
|
|
||||||
let close_elt = document.createElement('img')
|
|
||||||
close_elt.src = "/icons/close.svg"
|
|
||||||
close_elt.title = 'Cancel'
|
|
||||||
this.elements.action.appendChild(close_elt)
|
|
||||||
|
|
||||||
done_elt.addEventListener('click', () => {
|
|
||||||
this.name = name_input_elt.value
|
|
||||||
this.elements.title.textContent = this.name
|
|
||||||
this.secret = secret_input_elt.value
|
|
||||||
this.regenerateKey().then(() => { this.refresh(Math.floor(Date.now() / 30000)) })
|
|
||||||
store.save()
|
|
||||||
this.elements.main.removeChild(secret_container_elt)
|
|
||||||
this.elements.action.removeChild(done_elt)
|
|
||||||
this.elements.action.removeChild(close_elt)
|
|
||||||
this.elements.header.removeChild(name_input_elt)
|
|
||||||
this.closeEdit()
|
|
||||||
})
|
|
||||||
close_elt.addEventListener('click', () => {
|
|
||||||
this.elements.main.removeChild(secret_container_elt)
|
|
||||||
this.elements.action.removeChild(done_elt)
|
|
||||||
this.elements.action.removeChild(close_elt)
|
|
||||||
this.elements.header.removeChild(name_input_elt)
|
|
||||||
this.closeEdit()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
closeEdit()
|
|
||||||
{
|
|
||||||
this.elements.title.classList.remove('hidden')
|
|
||||||
this.elements.otp.classList.remove('hidden')
|
|
||||||
this.elements.edit.classList.remove('hidden')
|
|
||||||
this.elements.delete.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addEntry(event)
|
async function addEntry(event)
|
||||||
{
|
{
|
||||||
event.preventDefault()
|
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()
|
function start()
|
||||||
{
|
{
|
||||||
password_container_elt.classList.add('hidden')
|
password_container_elt.classList.add('hidden');
|
||||||
store_action_elt.classList.remove('hidden')
|
[store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => {
|
||||||
entry_add_container_elt.classList.remove('hidden')
|
elt.classList.remove('hidden'); });
|
||||||
const now = Date.now()
|
store.refresh();
|
||||||
if(refresh_timeout !== null)
|
|
||||||
clearTimeout(refresh_timeout)
|
|
||||||
refresh_timeout = setTimeout(
|
|
||||||
() => {
|
|
||||||
store.refresh()
|
|
||||||
},
|
|
||||||
((Math.floor(now / 30000) + 1) * 30000) - now + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop()
|
function stop()
|
||||||
{
|
{
|
||||||
store_action_elt.classList.add('hidden')
|
[store_action_elt, import_action_elt, entry_add_container_elt].forEach(elt => {
|
||||||
entry_add_container_elt.classList.add('hidden')
|
elt.classList.add('hidden');
|
||||||
password_container_elt.classList.remove('hidden')
|
});
|
||||||
if(refresh_timeout !== null)
|
password_container_elt.classList.remove('hidden');
|
||||||
clearTimeout(refresh_timeout)
|
store.close();
|
||||||
store.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let background_port = browser.runtime.connect({name:"background"});
|
|
||||||
|
|
||||||
async function init()
|
async function init()
|
||||||
{
|
{
|
||||||
password_form_elt.addEventListener('submit', (event) => {
|
password_form_elt.addEventListener('submit', (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
background_port.postMessage({command: 'key', password: master_password_elt.value})
|
background_port.postMessage({command: 'key', password: master_password_elt.value});
|
||||||
master_password_elt.value = ''
|
master_password_elt.value = '';
|
||||||
})
|
});
|
||||||
master_visibility_elt.addEventListener('click', () => {togglePassword(master_password_elt)})
|
master_visibility_elt.addEventListener('click', () => {togglePassword(master_password_elt);});
|
||||||
entry_form_elt.addEventListener('submit', addEntry)
|
entry_form_elt.addEventListener('submit', addEntry);
|
||||||
entry_visibility_elt.addEventListener('click', () => {togglePassword(entry_secret_elt)})
|
entry_visibility_elt.addEventListener('click', () => {togglePassword(entry_secret_elt);});
|
||||||
logout_elt.addEventListener('click', () => {
|
logout_elt.addEventListener('click', () => {
|
||||||
background_port.postMessage({command: 'logout', password: master_password_elt.value})
|
background_port.postMessage({command: 'logout', password: master_password_elt.value});
|
||||||
stop()
|
stop();
|
||||||
})
|
});
|
||||||
export_elt.addEventListener('click', () => { store.export() })
|
export_elt.addEventListener('click', () => { store.export(); });
|
||||||
|
import_elt.addEventListener('click', () => { browser.tabs.create({ url: "options.html" }); });
|
||||||
|
document.querySelector('#notification img').addEventListener(
|
||||||
|
'click', notification.close_notification.bind(notification));
|
||||||
|
|
||||||
background_port.postMessage({command: 'init'})
|
background_port.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')
|
||||||
|
notification.show_notification(message.data);
|
||||||
|
else
|
||||||
|
notification.show_error(`unexpected background message with type ${message.type}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
init().catch(error => console.error(error))
|
init().catch(error => {
|
||||||
|
notification.show_error(error);
|
||||||
|
});
|
||||||
|
|
|
||||||
493
store.js
Normal file
493
store.js
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
|
function b32encode(bytes)
|
||||||
|
{
|
||||||
|
let result = [];
|
||||||
|
let index = 0;
|
||||||
|
let lshift = 0;
|
||||||
|
let carry = 0;
|
||||||
|
let byte = bytes[index];
|
||||||
|
while(index < bytes.length)
|
||||||
|
{
|
||||||
|
if(lshift < 0) // need to right shift (should have a carry)
|
||||||
|
{
|
||||||
|
result.push(b32chars[carry + (byte >>> (-lshift + 3))]);
|
||||||
|
carry = 0;
|
||||||
|
lshift += 5;
|
||||||
|
}
|
||||||
|
else if(lshift > 3) // not enough bits to convert (save carry, next will be right shift)
|
||||||
|
{
|
||||||
|
carry = ((byte << lshift) & 248) >> 3;
|
||||||
|
lshift -= 8;
|
||||||
|
index += 1;
|
||||||
|
byte = bytes[index];
|
||||||
|
}
|
||||||
|
else // enough bits to convert
|
||||||
|
{
|
||||||
|
result.push(b32chars[((byte << lshift) & 248) >> 3]);
|
||||||
|
lshift += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(carry !== 0) // if carry left, add has last char
|
||||||
|
result.push(b32chars[carry]);
|
||||||
|
return result.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b32decode(text)
|
||||||
|
{
|
||||||
|
const up_text = text.toUpperCase();
|
||||||
|
let result = [];
|
||||||
|
let lshift = 3;
|
||||||
|
let carry = 0;
|
||||||
|
for(const index in up_text)
|
||||||
|
{
|
||||||
|
const char = up_text[index];
|
||||||
|
if(char === '=')
|
||||||
|
break;
|
||||||
|
let value = char.charCodeAt();
|
||||||
|
if(value >= 65 && value <= 90) // char in [A- Z]
|
||||||
|
value -= 65;
|
||||||
|
else if(value >= 50 && value <= 55) // char in [2-7]
|
||||||
|
value -= 24;
|
||||||
|
else
|
||||||
|
throw `Incorrect Base32 char '${text[index]}' at index ${index}`;
|
||||||
|
|
||||||
|
if(lshift > 0) // next value is needed to complete the octet
|
||||||
|
{
|
||||||
|
carry += value << lshift;
|
||||||
|
lshift -= 5;
|
||||||
|
if(lshift === 0) // last value aligned with octet
|
||||||
|
carry = 0;
|
||||||
|
}
|
||||||
|
else // getting the last octet' bits
|
||||||
|
{
|
||||||
|
result.push(carry + (value >>> (-lshift)));
|
||||||
|
carry = (value << (lshift + 8)) & 248; // if lshit is 0 this will always be 0
|
||||||
|
lshift += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(carry !== 0)
|
||||||
|
result.push(carry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePassword(elt)
|
||||||
|
{
|
||||||
|
if(elt.getAttribute('type') === 'password')
|
||||||
|
elt.setAttribute('type', 'text');
|
||||||
|
else
|
||||||
|
elt.setAttribute('type', 'password');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationCenter
|
||||||
|
{
|
||||||
|
constructor(notification_elt, notification_text_elt, notification_delay=5000, notification_repeat=500)
|
||||||
|
{
|
||||||
|
this.notifications = [];
|
||||||
|
this.notification_timeout = null;
|
||||||
|
this.notification_elt = notification_elt;
|
||||||
|
this.notification_text_elt = notification_text_elt;
|
||||||
|
this.notification_delay = notification_delay;
|
||||||
|
this.notification_repeat = notification_repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
show_notification(text)
|
||||||
|
{
|
||||||
|
this.notifications.push(text);
|
||||||
|
this._notification();
|
||||||
|
}
|
||||||
|
|
||||||
|
show_error(error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
this.show_notification(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
close_notification()
|
||||||
|
{
|
||||||
|
clearTimeout(this.notification_timeout);
|
||||||
|
this._next_notification();
|
||||||
|
}
|
||||||
|
|
||||||
|
_next_notification()
|
||||||
|
{
|
||||||
|
this.notification_timeout = null;
|
||||||
|
this.notification_elt.classList.remove('show');
|
||||||
|
this.notifications.shift();
|
||||||
|
if(this.notifications.length !== 0)
|
||||||
|
setTimeout(this._notification.bind(this), this.notification_repeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
_notification()
|
||||||
|
{
|
||||||
|
if(this.notifications.length !== 0 && this.notification_timeout === null)
|
||||||
|
{
|
||||||
|
this.notification_text_elt.firstChild.data = this.notifications[0];
|
||||||
|
this.notification_elt.classList.add('show');
|
||||||
|
this.notification_timeout = setTimeout(this._next_notification.bind(this), this.notification_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Store
|
||||||
|
{
|
||||||
|
constructor(notification_center, entry_list_elt=null)
|
||||||
|
{
|
||||||
|
this.entries = [];
|
||||||
|
this.iv = null;
|
||||||
|
this.salt = null;
|
||||||
|
this.key = null;
|
||||||
|
|
||||||
|
this.refresh_timeout = null;
|
||||||
|
|
||||||
|
this.entry_list_elt = entry_list_elt;
|
||||||
|
this.notification_center = notification_center;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadState(state)
|
||||||
|
{
|
||||||
|
this.iv = state.iv;
|
||||||
|
this.salt = state.salt;
|
||||||
|
this.key = state.key;
|
||||||
|
for(const entry of state.entries)
|
||||||
|
{
|
||||||
|
await this.addEntry(entry.name, entry.secret, false);
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addEntry(name, secret, save=true)
|
||||||
|
{
|
||||||
|
const entry = await Entry.create(this, name, secret, this.notification_center);
|
||||||
|
if(entry === null)
|
||||||
|
return;
|
||||||
|
if(this.entry_list_elt !== null)
|
||||||
|
this.entry_list_elt.appendChild(entry.elements.main);
|
||||||
|
this.entries.push(entry);
|
||||||
|
if(save)
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEntry(entry)
|
||||||
|
{
|
||||||
|
if(this.entry_list_elt !== null)
|
||||||
|
this.entry_list_elt.removeChild(entry.elements.main);
|
||||||
|
this.entries = this.entries.filter(test_entry => test_entry !== entry);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save()
|
||||||
|
{
|
||||||
|
if(this.key === null)
|
||||||
|
{
|
||||||
|
this.notification_center.show_error(`Cannot save store without key`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let encoder = new TextEncoder();
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{name: 'AES-GCM', iv: this.iv},
|
||||||
|
this.key,
|
||||||
|
encoder.encode(JSON.stringify(
|
||||||
|
this.entries.map(entry => {return {name: entry.name, secret: entry.secret};}))));
|
||||||
|
await browser.storage.local.set({
|
||||||
|
iv: b32encode(this.iv),
|
||||||
|
salt: b32encode(this.salt),
|
||||||
|
entries: b32encode(new Uint8Array(encrypted))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
{
|
||||||
|
const now = Date.now();
|
||||||
|
const count_time = now / 30000;
|
||||||
|
const counter = Math.floor(count_time);
|
||||||
|
const animation_delay = (count_time - counter) * 30;
|
||||||
|
|
||||||
|
if(this.refresh_timeout !== null)
|
||||||
|
clearTimeout(this.refresh_timeout);
|
||||||
|
this.refresh_timeout = setTimeout(
|
||||||
|
() => { this.refresh(); },
|
||||||
|
((counter + 1) * 30000) - now + 1);
|
||||||
|
|
||||||
|
for(const entry of this.entries)
|
||||||
|
{
|
||||||
|
entry.refresh(counter);
|
||||||
|
entry.refreshTimer(animation_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close()
|
||||||
|
{
|
||||||
|
if(this.refresh_timeout !== null)
|
||||||
|
clearTimeout(this.refresh_timeout);
|
||||||
|
if(this.entry_list_elt !== null)
|
||||||
|
this.entries.forEach(entry => { this.entry_list_elt.removeChild(entry.elements.main); });
|
||||||
|
this.entries = [];
|
||||||
|
this.key = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export()
|
||||||
|
{
|
||||||
|
const export_elt = document.createElement('a');
|
||||||
|
export_elt.setAttribute('href', 'data:application/json,' + JSON.stringify(
|
||||||
|
this.entries.map(entry => {return {name: entry.name, secret: entry.secret};})));
|
||||||
|
export_elt.setAttribute('download', 'uotp_export.json');
|
||||||
|
export_elt.style.display = 'none';
|
||||||
|
document.body.appendChild(export_elt);
|
||||||
|
export_elt.click();
|
||||||
|
document.body.removeChild(export_elt);
|
||||||
|
}
|
||||||
|
|
||||||
|
import()
|
||||||
|
{
|
||||||
|
const import_elt = document.createElement('input');
|
||||||
|
import_elt.setAttribute('type', 'file');
|
||||||
|
import_elt.onchange = event => {
|
||||||
|
if(event.target.files.length > 0)
|
||||||
|
{
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let import_json = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
import_json = JSON.parse(reader.result);
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
this.notification_center.show_error('Error reading import file : not a valid json.');
|
||||||
|
}
|
||||||
|
if(!Array.isArray(import_json))
|
||||||
|
{
|
||||||
|
this.notification_center.show_error(
|
||||||
|
'Invalid imported file : expecting a list of entries, imported JSON is not a list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errors = [];
|
||||||
|
import_json.forEach((entry, index) => {
|
||||||
|
if(!entry.name)
|
||||||
|
{
|
||||||
|
errors.push(`Invalid entry #${index} in imported file : "name" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!entry.secret)
|
||||||
|
{
|
||||||
|
errors.push(`Invalid entry #${index} in imported file : "secret" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addEntry(entry.name, entry.secret);
|
||||||
|
});
|
||||||
|
if(errors.length > 0)
|
||||||
|
this.notification_center.show_error(
|
||||||
|
`${errors.length} errors in imported file :\n${errors.join('\n')}`);
|
||||||
|
};
|
||||||
|
reader.readAsText(event.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.appendChild(import_elt);
|
||||||
|
import_elt.click();
|
||||||
|
document.body.removeChild(import_elt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Entry
|
||||||
|
{
|
||||||
|
constructor(store, name, secret, key)
|
||||||
|
{
|
||||||
|
this.store = store;
|
||||||
|
this.name = name;
|
||||||
|
this.secret = secret;
|
||||||
|
this.key = key;
|
||||||
|
this.timer_count = 0;
|
||||||
|
|
||||||
|
let new_elt = document.createElement('div');
|
||||||
|
new_elt.className = 'entry';
|
||||||
|
|
||||||
|
let header_elt = document.createElement('div');
|
||||||
|
header_elt.className = 'header';
|
||||||
|
// Entry title
|
||||||
|
let title_elt = document.createElement('h2');
|
||||||
|
title_elt.textContent = name;
|
||||||
|
header_elt.appendChild(title_elt);
|
||||||
|
|
||||||
|
// Entry actions
|
||||||
|
let action_elt = document.createElement('div');
|
||||||
|
action_elt.className = "action";
|
||||||
|
let edit_elt = document.createElement('img');
|
||||||
|
edit_elt.src = "/icons/edit.svg";
|
||||||
|
edit_elt.title = 'Edit';
|
||||||
|
edit_elt.addEventListener('click', () => {this.openEdit();});
|
||||||
|
action_elt.appendChild(edit_elt);
|
||||||
|
let delete_elt = document.createElement('img');
|
||||||
|
delete_elt.src = "/icons/delete.svg";
|
||||||
|
delete_elt.title = 'Delete';
|
||||||
|
delete_elt.addEventListener('click', () => { this.store.removeEntry(this); });
|
||||||
|
action_elt.appendChild(delete_elt);
|
||||||
|
header_elt.appendChild(action_elt);
|
||||||
|
|
||||||
|
new_elt.appendChild(header_elt);
|
||||||
|
|
||||||
|
// Entry OTP
|
||||||
|
let otp_container_elt = document.createElement('div');
|
||||||
|
otp_container_elt.className = 'otp-container';
|
||||||
|
let otp_elt = document.createElement('div');
|
||||||
|
otp_elt.className = 'otp';
|
||||||
|
otp_elt.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(otp_elt.textContent);
|
||||||
|
});
|
||||||
|
otp_container_elt.appendChild(otp_elt);
|
||||||
|
// let svg_timer_elt = document.createElement('svg')
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
let svg_timer_elt = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg_timer_elt.setAttributeNS(null, 'viewBox', '0 0 2 2');
|
||||||
|
let timer_circle_elt = document.createElementNS(svgNS, 'circle');
|
||||||
|
timer_circle_elt.setAttributeNS(null, 'r', '1');
|
||||||
|
timer_circle_elt.setAttributeNS(null, 'cx', '1');
|
||||||
|
timer_circle_elt.setAttributeNS(null, 'cy', '1');
|
||||||
|
svg_timer_elt.appendChild(timer_circle_elt);
|
||||||
|
otp_container_elt.appendChild(svg_timer_elt);
|
||||||
|
new_elt.appendChild(otp_container_elt);
|
||||||
|
|
||||||
|
this.elements = {
|
||||||
|
main: new_elt,
|
||||||
|
header: header_elt,
|
||||||
|
title: title_elt,
|
||||||
|
otp: otp_elt,
|
||||||
|
svg_timer_elt: svg_timer_elt,
|
||||||
|
timer_circle: timer_circle_elt,
|
||||||
|
action: action_elt,
|
||||||
|
edit: edit_elt,
|
||||||
|
delete: delete_elt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// static function to construct Entry while being async
|
||||||
|
static async create(store, name, secret, notification_center)
|
||||||
|
{
|
||||||
|
let decoded_secret = '';
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decoded_secret = new Uint8Array(b32decode(secret));
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
notification_center.show_error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = await window.crypto.subtle.importKey(
|
||||||
|
'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']);
|
||||||
|
const new_entry = new Entry(store, name, secret, key);
|
||||||
|
const count_time = Date.now() / 30000;
|
||||||
|
const counter = Math.floor(count_time);
|
||||||
|
await new_entry.refresh(counter);
|
||||||
|
new_entry.refreshTimer((count_time - counter) * 30);
|
||||||
|
return new_entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerateKey()
|
||||||
|
{
|
||||||
|
let decoded_secret = '';
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decoded_secret = new Uint8Array(b32decode(this.secret));
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
this.notification_center.show_error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.key = await window.crypto.subtle.importKey(
|
||||||
|
'raw', decoded_secret, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(counter)
|
||||||
|
{
|
||||||
|
// TOTP algorithm is here
|
||||||
|
|
||||||
|
// Set timestamp as byte array for hmac hash (4 bytes is enough)
|
||||||
|
const buffer = new ArrayBuffer(8); // 8 byte buffer needed for HMAC (initialized with 0)
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
view.setUint32(4, counter);
|
||||||
|
// HMAC returns am ArrayBuffer wich is unusable as is (no type), using Uint8Array is needed
|
||||||
|
const signature = new Uint8Array(await window.crypto.subtle.sign("hmac", this.key, buffer));
|
||||||
|
// Getting the offset from the last byte with its highest bit masked to 0
|
||||||
|
const offset = signature[signature.length - 1] & 15;
|
||||||
|
// Masking the highest bit of the byte located at calculated offset
|
||||||
|
signature[offset] = signature[offset] & 127;
|
||||||
|
// Reading 4 bytes from the hash from the offset (highest byte was masked) and getting the last
|
||||||
|
// 6 digits from its Uint32 representation
|
||||||
|
this.elements.otp.textContent = new DataView(
|
||||||
|
signature.slice(offset, offset + 4).buffer).getUint32().toString().slice(-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimer(animation_delay)
|
||||||
|
{
|
||||||
|
this.timer_count = 1 - this.timer_count;
|
||||||
|
this.elements.timer_circle.style.animationDelay = `-${animation_delay}s`;
|
||||||
|
this.elements.timer_circle.style.animationName = `timer${this.timer_count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
openEdit()
|
||||||
|
{
|
||||||
|
[this.elements.title, this.elements.otp, this.elements.edit, this.elements.delete,
|
||||||
|
this.elements.svg_timer_elt].forEach(elt => {elt.classList.add('hidden');} );
|
||||||
|
|
||||||
|
let name_input_elt = document.createElement('input');
|
||||||
|
name_input_elt.value = this.name;
|
||||||
|
this.elements.header.insertBefore(name_input_elt, this.elements.action);
|
||||||
|
|
||||||
|
let secret_container_elt = document.createElement('div');
|
||||||
|
secret_container_elt.className = 'password-container';
|
||||||
|
let secret_input_elt = document.createElement('input');
|
||||||
|
secret_input_elt.setAttribute('type', 'password');
|
||||||
|
secret_input_elt.value = this.secret;
|
||||||
|
secret_container_elt.appendChild(secret_input_elt);
|
||||||
|
let secret_visibility_elt = document.createElement('img');
|
||||||
|
secret_visibility_elt.className = 'icon visibility';
|
||||||
|
secret_visibility_elt.src = '/icons/visibility.svg';
|
||||||
|
secret_container_elt.appendChild(secret_visibility_elt);
|
||||||
|
secret_visibility_elt.addEventListener('click', () => {togglePassword(secret_input_elt);});
|
||||||
|
this.elements.main.appendChild(secret_container_elt);
|
||||||
|
|
||||||
|
let done_elt = document.createElement('img');
|
||||||
|
done_elt.src = "/icons/done.svg";
|
||||||
|
done_elt.title = 'Save';
|
||||||
|
this.elements.action.appendChild(done_elt);
|
||||||
|
let close_elt = document.createElement('img');
|
||||||
|
close_elt.src = "/icons/close.svg";
|
||||||
|
close_elt.title = 'Cancel';
|
||||||
|
this.elements.action.appendChild(close_elt);
|
||||||
|
|
||||||
|
done_elt.addEventListener('click', () => {
|
||||||
|
this.name = name_input_elt.value;
|
||||||
|
this.elements.title.textContent = this.name;
|
||||||
|
// Remove any white spaces (can happen for readability)
|
||||||
|
this.secret = secret_input_elt.value.replace(/\s/g, '');
|
||||||
|
this.regenerateKey().then(() => {
|
||||||
|
const count_time = Date.now() / 30000;
|
||||||
|
const counter = Math.floor(count_time);
|
||||||
|
this.refresh(counter);
|
||||||
|
this.refreshTimer((count_time - counter) * 30);
|
||||||
|
});
|
||||||
|
this.store.save();
|
||||||
|
this.elements.main.removeChild(secret_container_elt);
|
||||||
|
this.elements.action.removeChild(done_elt);
|
||||||
|
this.elements.action.removeChild(close_elt);
|
||||||
|
this.elements.header.removeChild(name_input_elt);
|
||||||
|
this.closeEdit();
|
||||||
|
});
|
||||||
|
close_elt.addEventListener('click', () => {
|
||||||
|
this.elements.main.removeChild(secret_container_elt);
|
||||||
|
this.elements.action.removeChild(done_elt);
|
||||||
|
this.elements.action.removeChild(close_elt);
|
||||||
|
this.elements.header.removeChild(name_input_elt);
|
||||||
|
this.closeEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEdit()
|
||||||
|
{
|
||||||
|
[this.elements.title, this.elements.otp, this.elements.edit, this.elements.delete,
|
||||||
|
this.elements.svg_timer_elt].forEach(elt => { elt.classList.remove('hidden'); } );
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue