04/20/2026 enhance the extension totp
This commit is contained in:
@@ -901,4 +901,34 @@ body {
|
||||
.add-notes:focus {
|
||||
border-color: #c0392b;
|
||||
box-shadow: 0 0 0 2px rgba(192, 57, 43, 0.12);
|
||||
}
|
||||
|
||||
/* ── TOTP live display in vault item rows ────────────────────────── */
|
||||
.item-totp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.totp-code-inline {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #1a73e8;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.totp-timer-inline {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.totp-btn {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.totp-btn:hover {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
+105
-1
@@ -342,6 +342,68 @@ async function fetchAndDecryptVault() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOTP engine (RFC 6238) — pure Web Crypto, no library ─────────────────────
|
||||
|
||||
function _extractTotpSecret(uri) {
|
||||
if (!uri) return null;
|
||||
uri = uri.trim();
|
||||
if (uri.startsWith('otpauth://')) {
|
||||
try {
|
||||
const secret = new URL(uri).searchParams.get('secret');
|
||||
return secret ? secret.toUpperCase().replace(/\s+/g, '') : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
return uri.toUpperCase().replace(/[\s-]/g, '') || null;
|
||||
}
|
||||
|
||||
function _base32ToBytes(b32) {
|
||||
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
b32 = b32.replace(/=+$/, '');
|
||||
let bits = 0, val = 0;
|
||||
const out = [];
|
||||
for (const c of b32) {
|
||||
const idx = CHARS.indexOf(c);
|
||||
if (idx === -1) continue;
|
||||
val = (val << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); }
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
async function getTotpCode(totpUri) {
|
||||
const secret = _extractTotpSecret(totpUri);
|
||||
if (!secret) return null;
|
||||
const keyBytes = _base32ToBytes(secret);
|
||||
if (!keyBytes.length) return null;
|
||||
|
||||
const counter = Math.floor(Date.now() / 1000 / 30);
|
||||
const msg = new Uint8Array(8);
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
|
||||
);
|
||||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, msg));
|
||||
const offset = sig[19] & 0x0f;
|
||||
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 |
|
||||
sig[offset + 2] << 8 | sig[offset + 3]) % 1_000_000;
|
||||
return String(code).padStart(6, '0');
|
||||
}
|
||||
|
||||
function _totpSecondsLeft() {
|
||||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
}
|
||||
|
||||
// Track active TOTP ticker intervals so we can clear them on re-render.
|
||||
let _totpIntervals = [];
|
||||
|
||||
function _clearTotpTickers() {
|
||||
_totpIntervals.forEach(id => clearInterval(id));
|
||||
_totpIntervals = [];
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getTabItems() {
|
||||
@@ -375,6 +437,9 @@ function renderList() {
|
||||
const emptyEl = $('vault-empty');
|
||||
const items = getTabItems();
|
||||
|
||||
// Stop any running TOTP tickers from a previous render.
|
||||
_clearTotpTickers();
|
||||
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = '';
|
||||
if (_activeTab === 'relevant') {
|
||||
@@ -394,6 +459,7 @@ function renderList() {
|
||||
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
|
||||
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
|
||||
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
||||
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||||
|
||||
listEl.innerHTML = items.map(item => {
|
||||
const matched = isMatch(item);
|
||||
@@ -404,21 +470,44 @@ function renderList() {
|
||||
const emoji = itemEmoji(item.item_type);
|
||||
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
|
||||
const canCopy = item.item_type === 'password' && item.plain?.password;
|
||||
const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri);
|
||||
|
||||
return `<div class="vault-item" data-id="${item.id}">
|
||||
<div class="item-avatar ${color}">${emoji}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-site">${site}${badge}</div>
|
||||
<div class="item-name">${name}</div>
|
||||
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ''}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ''}
|
||||
${hasTotp ? `<button class="btn-item-action totp-btn" data-copy-totp="${item.id}" title="Copy 2FA code">${svgTotp}</button>` : ''}
|
||||
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ''}
|
||||
<button class="btn-item-action" data-menu="${item.id}" title="More">${svgDots}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Start TOTP tickers for items that have a totp_uri.
|
||||
items.forEach(item => {
|
||||
if (item.item_type !== 'password' || !_extractTotpSecret(item.plain?.totp_uri)) return;
|
||||
const codeEl = $(`totp-${item.id}`);
|
||||
const timerEl = $(`totp-t-${item.id}`);
|
||||
if (!codeEl) return;
|
||||
|
||||
async function tick() {
|
||||
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
||||
if (!code || !codeEl.isConnected) return;
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
const secs = _totpSecondsLeft();
|
||||
timerEl.textContent = ' ' + secs + 's';
|
||||
timerEl.style.color = secs <= 5 ? '#dc2626' : '#9ca3af';
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
_totpIntervals.push(id);
|
||||
});
|
||||
|
||||
// Copy password
|
||||
listEl.querySelectorAll('[data-copy-pass]').forEach(btn =>
|
||||
btn.addEventListener('click', e => {
|
||||
@@ -432,6 +521,22 @@ function renderList() {
|
||||
})
|
||||
);
|
||||
|
||||
// Copy TOTP code
|
||||
listEl.querySelectorAll('[data-copy-totp]').forEach(btn =>
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const item = _items.find(i => i.id === parseInt(btn.dataset.copyTotp));
|
||||
if (!item?.plain?.totp_uri) return;
|
||||
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code);
|
||||
btn.title = 'Copied!';
|
||||
btn.style.color = '#16a34a';
|
||||
setTimeout(() => { btn.title = 'Copy 2FA code'; btn.style.color = ''; }, 1500);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Autofill
|
||||
listEl.querySelectorAll('[data-autofill]').forEach(btn =>
|
||||
btn.addEventListener('click', async e => {
|
||||
@@ -456,7 +561,6 @@ function renderList() {
|
||||
e.stopPropagation();
|
||||
const item = _items.find(i => i.id === parseInt(btn.dataset.menu));
|
||||
if (!item?.plain) return;
|
||||
// Simple: copy username on dots click (could be a dropdown in future)
|
||||
if (item.plain.username) {
|
||||
navigator.clipboard.writeText(item.plain.username);
|
||||
btn.title = 'Username copied!';
|
||||
|
||||
Reference in New Issue
Block a user