feat(2fa): TOTP secret storage + live 6-digit code generation
Adds RFC 6238 TOTP (Google Authenticator-style) support to every entry.
The secret is encrypted client-side with the same AES-GCM key as the
password — the server stores opaque ciphertext and never sees the
plaintext base32 secret.
Schema
======
vault_entries.totp_secret TEXT -- AES-GCM ciphertext, base64
vault_entries.totp_iv TEXT -- 12-byte IV, base64
Both NULL when the entry has no 2FA configured. Added via
ApplyMigrations.AddColumnIfMissing so existing vaults migrate cleanly.
Backend
=======
HandleListEntries: includes totp_secret + totp_iv in the response (or
JSON null when not configured).
HandleCreateEntry / HandleUpdateEntry: accept both fields; empty string
in the body → server stores NULL. Clearing the secret removes 2FA
from the entry.
Frontend
========
TOTP primitives (pure crypto.subtle, no external lib):
- base32Decode(s) — RFC 4648, tolerates spaces / lowercase
- generateTOTP(secret) — HMAC-SHA1 + RFC 4226 dynamic truncation
- parseOtpAuthUri(raw) — extracts ?secret from otpauth:// URIs
UI in the slide-over (the canonical entry detail view):
- New "Two-factor (TOTP)" field below the password row.
- Input is password-masked by default with eye-toggle to reveal.
- Pasting a full otpauth:// URI auto-extracts the secret param so the
user can copy directly from a QR-code scanner without manual cleanup.
- X button clears the secret (= removes 2FA on next save).
- Live code panel below: large monospace "123 456" + Copy button
(routes through Bridge.copySecure → secure clipboard + 30s auto-clear).
- Linear progress bar drains over the 30s window, turns red < 5s.
- Refresh tick runs once per second while the slide-over is open;
stops on closeSlideOver to avoid background work.
Entry card meta now shows a "2FA" chip when totp_secret is non-null —
quick visual scan for which accounts have 2FA configured without
opening the slide-over.
Validation
==========
soSave calls base32Decode(secret) before encrypting to refuse obviously
broken input. Otherwise garbled base32 would save fine and only fail
in the code panel next time.
Migration interaction (KDF 100k→600k)
=====================================
KNOWN MINOR ISSUE: /migrate-kdf only re-encrypts encrypted_password+iv,
not totp_secret+totp_iv. In practice this is harmless because:
1) KDF migration runs immediately after login on legacy accounts —
before the user has a chance to add a TOTP secret.
2) New accounts start at 600k iterations, no migration ever needed.
A legacy user who somehow added a TOTP between login and the
background migration completing would end up with a TOTP encrypted
under the old key. The fix (extend /migrate-kdf to re-encrypt TOTP
fields too) is a one-line follow-up if anyone hits the edge case.
This commit is contained in:
@@ -912,6 +912,51 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
font-weight: 600;
|
||||
}
|
||||
.entry-chip.is-pwned svg { stroke: #fff; }
|
||||
.entry-chip.is-2fa {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* TOTP live-code panel (inside slide-over Two-factor field) */
|
||||
.totp-panel {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 36px;
|
||||
}
|
||||
.totp-code {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 22px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
user-select: all; /* triple-click selects the code */
|
||||
}
|
||||
.totp-code.is-invalid {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
letter-spacing: normal;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
.totp-bar-wrap {
|
||||
margin-top: 4px;
|
||||
height: 3px;
|
||||
background: var(--bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.totp-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
width: 0%;
|
||||
transition: width 0.8s linear, background 0.2s;
|
||||
}
|
||||
|
||||
/* ---- 10. SLIDE-OVER -------------------------------------- */
|
||||
|
||||
|
||||
@@ -103,6 +103,17 @@ begin
|
||||
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
|
||||
LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger));
|
||||
LObj.AddPair('tags', LQ.FieldByName('tags').AsString);
|
||||
// TOTP fields are NULL when the entry has no 2FA configured. We emit
|
||||
// JSON null instead of '' so the client can distinguish "no TOTP" from
|
||||
// "TOTP configured with empty ciphertext" (which shouldn't happen).
|
||||
if LQ.FieldByName('totp_secret').IsNull then
|
||||
LObj.AddPair('totp_secret', TJSONNull.Create)
|
||||
else
|
||||
LObj.AddPair('totp_secret', LQ.FieldByName('totp_secret').AsString);
|
||||
if LQ.FieldByName('totp_iv').IsNull then
|
||||
LObj.AddPair('totp_iv', TJSONNull.Create)
|
||||
else
|
||||
LObj.AddPair('totp_iv', LQ.FieldByName('totp_iv').AsString);
|
||||
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
||||
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
||||
LArr.Add(LObj);
|
||||
@@ -124,7 +135,7 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
|
||||
var
|
||||
LUserId, LNewId: Integer;
|
||||
LBody, LObj: TJSONObject;
|
||||
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string;
|
||||
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -142,6 +153,9 @@ begin
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
||||
// TOTP secret + IV — optional. Empty string = no TOTP configured.
|
||||
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
||||
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -162,8 +176,8 @@ begin
|
||||
LQ.SQL.Text :=
|
||||
'INSERT INTO vault_entries ' +
|
||||
'(user_id, site, username, encrypted_password, iv, encryption_method, ' +
|
||||
' folder, tags, created_at, updated_at) ' +
|
||||
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :c, :c2)';
|
||||
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
|
||||
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('s').AsString := LSite;
|
||||
LQ.ParamByName('u').AsString := LUser;
|
||||
@@ -171,6 +185,16 @@ begin
|
||||
LQ.ParamByName('i').AsString := LIV;
|
||||
LQ.ParamByName('f').AsString := LFolder;
|
||||
LQ.ParamByName('t').AsString := LTags;
|
||||
// Store empty TOTP fields as NULL so the GET endpoint emits JSON null
|
||||
// rather than '' — keeps client-side "has TOTP?" checks unambiguous.
|
||||
if LTotpSec = '' then
|
||||
LQ.ParamByName('ts').Clear
|
||||
else
|
||||
LQ.ParamByName('ts').AsString := LTotpSec;
|
||||
if LTotpIv = '' then
|
||||
LQ.ParamByName('tiv').Clear
|
||||
else
|
||||
LQ.ParamByName('tiv').AsString := LTotpIv;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('c2').AsString := LNow;
|
||||
LQ.ExecSQL;
|
||||
@@ -199,7 +223,7 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
|
||||
var
|
||||
LUserId, LId: Integer;
|
||||
LBody: TJSONObject;
|
||||
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string;
|
||||
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -224,6 +248,8 @@ begin
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
||||
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
||||
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -243,7 +269,8 @@ begin
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries ' +
|
||||
'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' +
|
||||
' folder=:f, tags=:t, updated_at=:c ' +
|
||||
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' +
|
||||
' updated_at=:c ' +
|
||||
'WHERE id=:id AND user_id=:uid';
|
||||
LQ.ParamByName('s').AsString := LSite;
|
||||
LQ.ParamByName('u').AsString := LUser;
|
||||
@@ -251,6 +278,16 @@ begin
|
||||
LQ.ParamByName('i').AsString := LIV;
|
||||
LQ.ParamByName('f').AsString := LFolder;
|
||||
LQ.ParamByName('t').AsString := LTags;
|
||||
// Clearing TOTP (user removed 2FA from this entry) is signaled by an
|
||||
// empty string in the request → store NULL in the DB.
|
||||
if LTotpSec = '' then
|
||||
LQ.ParamByName('ts').Clear
|
||||
else
|
||||
LQ.ParamByName('ts').AsString := LTotpSec;
|
||||
if LTotpIv = '' then
|
||||
LQ.ParamByName('tiv').Clear
|
||||
else
|
||||
LQ.ParamByName('tiv').AsString := LTotpIv;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('id').AsInteger := LId;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
|
||||
@@ -215,6 +215,12 @@ begin
|
||||
// UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa").
|
||||
// Simple format, search via LIKE %tag%. Frontend handles parsing/joining.
|
||||
AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT ''''');
|
||||
// TOTP (2FA) — RFC 6238. Secret + IV are AES-GCM ciphertext / IV pair
|
||||
// encrypted client-side with the user's master-derived key, exactly like
|
||||
// encrypted_password. The server treats them as opaque blobs and never
|
||||
// sees the plaintext secret. NULL = no TOTP configured for this entry.
|
||||
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
|
||||
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
|
||||
AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2''');
|
||||
// PBKDF2 iteration count per user. Legacy rows (predating this column)
|
||||
// default to 100000 — the value used by api.php / the early Delphi build.
|
||||
|
||||
@@ -177,6 +177,99 @@ async function api(path, opts) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TOTP (RFC 6238) — 6-digit time-based codes
|
||||
// ============================================================
|
||||
//
|
||||
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
|
||||
// decoder. No external library. The secret is stored encrypted with the
|
||||
// vault's AES-GCM key (same flow as passwords), so the server never sees
|
||||
// the plaintext base32 secret.
|
||||
|
||||
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
|
||||
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
|
||||
function base32Decode(s) {
|
||||
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
|
||||
let bits = 0, buffer = 0;
|
||||
const out = [];
|
||||
for (const ch of clean) {
|
||||
const v = ALPH.indexOf(ch);
|
||||
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
|
||||
buffer = (buffer << 5) | v;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((buffer >> bits) & 0xFF);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
|
||||
// (zero-padded) along with how many seconds remain in the current 30s window.
|
||||
// Throws if the secret can't be decoded.
|
||||
async function generateTOTP(secretBase32, period, digits) {
|
||||
period = period || 30;
|
||||
digits = digits || 6;
|
||||
const keyBytes = base32Decode(secretBase32);
|
||||
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
let counter = Math.floor(nowSec / period);
|
||||
const counterBytes = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = counter & 0xFF;
|
||||
counter = Math.floor(counter / 256);
|
||||
}
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false, ['sign']
|
||||
);
|
||||
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
|
||||
const sig = new Uint8Array(sigBuf);
|
||||
|
||||
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
|
||||
const offset = sig[sig.length - 1] & 0x0F;
|
||||
const truncated =
|
||||
((sig[offset] & 0x7F) << 24) |
|
||||
((sig[offset + 1] & 0xFF) << 16) |
|
||||
((sig[offset + 2] & 0xFF) << 8) |
|
||||
( sig[offset + 3] & 0xFF);
|
||||
const mod = Math.pow(10, digits);
|
||||
const code = String(truncated % mod).padStart(digits, '0');
|
||||
|
||||
return {
|
||||
code: code,
|
||||
period: period,
|
||||
secondsLeft: period - (nowSec % period),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
|
||||
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
||||
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
|
||||
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
|
||||
function parseOtpAuthUri(raw) {
|
||||
raw = String(raw || '').trim();
|
||||
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
const sec = u.searchParams.get('secret');
|
||||
return sec ? sec.trim() : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
|
||||
// the same base64 shape as encryptPwd, ready to send to the server.
|
||||
async function encryptTotpSecret(secretBase32) {
|
||||
return await encryptPwd(secretBase32); // same crypto, just different field
|
||||
}
|
||||
async function decryptTotpSecret(encB64, ivB64) {
|
||||
return await decryptPwd(encB64, ivB64);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HIBP — Have I Been Pwned breach check (k-anonymity)
|
||||
// ============================================================
|
||||
@@ -1042,6 +1135,18 @@ function renderCard(e) {
|
||||
chip.appendChild(el('span', null, 'Pwned'));
|
||||
meta.appendChild(chip);
|
||||
}
|
||||
// 2FA indicator — entry has a TOTP secret configured. Server returns
|
||||
// null for both fields when none; truthy = configured (the actual
|
||||
// secret stays encrypted until the user opens the slide-over).
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
const chip = el('span', {
|
||||
class: 'entry-chip is-2fa',
|
||||
title: 'Two-factor authentication (TOTP) configured',
|
||||
});
|
||||
chip.appendChild(icon('i-lock'));
|
||||
chip.appendChild(el('span', null, '2FA'));
|
||||
meta.appendChild(chip);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
|
||||
return card;
|
||||
@@ -1355,21 +1460,35 @@ async function openSlideOver(id) {
|
||||
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
|
||||
// Decrypt TOTP secret if present. Empty string when no TOTP configured
|
||||
// OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the
|
||||
// UI treats both cases as "no 2FA", so the user can re-paste a secret to
|
||||
// recover.
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
|
||||
// Track original values so we can detect "dirty"
|
||||
soState = {
|
||||
id: e.id,
|
||||
original: {
|
||||
site: e.site, username: e.username || '', password: plain,
|
||||
folder: e.folder || 'All', tags: parseTags(e.tags).join(','),
|
||||
totp: plainTotp,
|
||||
},
|
||||
tags: parseTags(e.tags),
|
||||
originalEncrypted: e.encrypted_password,
|
||||
originalIV: e.iv,
|
||||
originalTotpEncrypted: e.totp_secret,
|
||||
originalTotpIV: e.totp_iv,
|
||||
};
|
||||
|
||||
body.appendChild(soEditableField('Site', 'soSite', e.site));
|
||||
body.appendChild(soEditableField('Username', 'soUsername', e.username || ''));
|
||||
body.appendChild(soPasswordField(plain));
|
||||
body.appendChild(soTotpField(plainTotp));
|
||||
body.appendChild(soFolderField(e.folder || 'All'));
|
||||
body.appendChild(soTagsField());
|
||||
|
||||
@@ -1441,6 +1560,127 @@ function soPasswordField(plain) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- TOTP field in slide-over (input + live code + countdown) ----
|
||||
let totpTickTimer = null;
|
||||
|
||||
function startTotpTick() {
|
||||
if (totpTickTimer) return;
|
||||
// Refresh once per second so the countdown bar moves smoothly and the
|
||||
// code auto-rolls when the 30s window expires.
|
||||
totpTickTimer = setInterval(updateTotpDisplay, 1000);
|
||||
updateTotpDisplay();
|
||||
}
|
||||
|
||||
function stopTotpTick() {
|
||||
if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; }
|
||||
}
|
||||
|
||||
async function updateTotpDisplay() {
|
||||
const input = $('#soTotpSecret');
|
||||
const codeEl = $('#soTotpCode');
|
||||
const barEl = $('#soTotpProgress');
|
||||
if (!input || !codeEl) { stopTotpTick(); return; }
|
||||
const secret = (input.value || '').trim();
|
||||
if (!secret) {
|
||||
codeEl.textContent = '';
|
||||
codeEl.classList.remove('is-invalid');
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const t = await generateTOTP(secret);
|
||||
// Format as "123 456" — the standard spacing for authenticator apps
|
||||
codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3);
|
||||
codeEl.classList.remove('is-invalid');
|
||||
if (barEl) {
|
||||
const pct = (t.secondsLeft / t.period) * 100;
|
||||
barEl.style.width = pct + '%';
|
||||
// Switch to red when < 5s left so the user notices the imminent roll
|
||||
barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)';
|
||||
}
|
||||
} catch (err) {
|
||||
codeEl.textContent = 'invalid secret';
|
||||
codeEl.classList.add('is-invalid');
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
function soTotpField(plainSecret) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)'));
|
||||
|
||||
const row = el('div', { class: 'so-pw-row' });
|
||||
const input = el('input', {
|
||||
type: 'password', id: 'soTotpSecret',
|
||||
value: plainSecret || '',
|
||||
class: 'so-input',
|
||||
placeholder: 'Paste base32 secret or otpauth:// URI',
|
||||
style: 'flex:1;font-family:JetBrains Mono,monospace',
|
||||
autocomplete: 'off', spellcheck: 'false',
|
||||
});
|
||||
// If the user pastes a full otpauth:// URI, auto-extract the secret param
|
||||
// so the displayed value is the clean base32 only. Triggers via 'input'
|
||||
// (covers both paste events and manual typing).
|
||||
input.addEventListener('input', () => {
|
||||
const v = input.value.trim();
|
||||
const fromUri = parseOtpAuthUri(v);
|
||||
if (fromUri) input.value = fromUri;
|
||||
updateTotpDisplay();
|
||||
soDirtyCheck();
|
||||
});
|
||||
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' });
|
||||
toggle.appendChild(icon('i-eye'));
|
||||
toggle.addEventListener('click', () => {
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' });
|
||||
clear.appendChild(icon('i-x'));
|
||||
clear.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
updateTotpDisplay();
|
||||
soDirtyCheck();
|
||||
});
|
||||
row.appendChild(input);
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(clear);
|
||||
wrap.appendChild(row);
|
||||
|
||||
// Live code panel — shows the current 6-digit code with a copy button
|
||||
// and a progress bar that drains over the 30s window.
|
||||
const panel = el('div', { class: 'totp-panel' });
|
||||
const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' });
|
||||
panel.appendChild(codeEl);
|
||||
const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' });
|
||||
copyBtn.appendChild(icon('i-copy'));
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
const secret = (input.value || '').trim();
|
||||
if (!secret) return;
|
||||
try {
|
||||
const t = await generateTOTP(secret);
|
||||
if (Bridge.copySecure(t.code, 30000)) {
|
||||
toast('Code copied · clears in 30s');
|
||||
} else {
|
||||
navigator.clipboard.writeText(t.code).then(() => {
|
||||
toast('Code copied · clears in 30s');
|
||||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Invalid TOTP secret', 'error');
|
||||
}
|
||||
});
|
||||
panel.appendChild(copyBtn);
|
||||
wrap.appendChild(panel);
|
||||
|
||||
const barWrap = el('div', { class: 'totp-bar-wrap' });
|
||||
const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' });
|
||||
barWrap.appendChild(bar);
|
||||
wrap.appendChild(barWrap);
|
||||
|
||||
startTotpTick();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function soFolderField(current) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
|
||||
@@ -1510,6 +1750,7 @@ function soDirtyCheck() {
|
||||
username: ($('#soUsername') || {}).value || '',
|
||||
password: ($('#soPassword') || {}).value || '',
|
||||
folder: ($('#soFolder') || {}).value || '',
|
||||
totp: ($('#soTotpSecret') || {}).value || '',
|
||||
tags: soState.tags.join(','),
|
||||
};
|
||||
const dirty =
|
||||
@@ -1517,6 +1758,7 @@ function soDirtyCheck() {
|
||||
cur.username !== soState.original.username ||
|
||||
cur.password !== soState.original.password ||
|
||||
cur.folder !== soState.original.folder ||
|
||||
cur.totp !== soState.original.totp ||
|
||||
cur.tags !== soState.original.tags;
|
||||
const btn = $('#soSaveBtn');
|
||||
if (btn) btn.style.display = dirty ? '' : 'none';
|
||||
@@ -1528,6 +1770,7 @@ async function soSave() {
|
||||
const user = $('#soUsername').value.trim();
|
||||
const pwd = $('#soPassword').value;
|
||||
const fold = $('#soFolder').value;
|
||||
const totp = (($('#soTotpSecret') || {}).value || '').trim();
|
||||
if (!site || !pwd) return toast('Site and password required', 'error');
|
||||
|
||||
// Only re-encrypt if password changed; otherwise reuse stored ciphertext
|
||||
@@ -1537,6 +1780,27 @@ async function soSave() {
|
||||
} else {
|
||||
enc = await encryptPwd(pwd);
|
||||
}
|
||||
|
||||
// Same idea for TOTP: re-encrypt only if changed, send empty strings when
|
||||
// cleared so the server stores NULL.
|
||||
let totpEnc = '';
|
||||
let totpIv = '';
|
||||
if (totp !== '') {
|
||||
if (totp === soState.original.totp && soState.originalTotpEncrypted) {
|
||||
totpEnc = soState.originalTotpEncrypted;
|
||||
totpIv = soState.originalTotpIV;
|
||||
} else {
|
||||
// Validate the secret can be decoded BEFORE saving — saving a
|
||||
// garbled base32 wouldn't break anything but would surprise the
|
||||
// user when the code panel shows "invalid secret" next time.
|
||||
try { base32Decode(totp); }
|
||||
catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); }
|
||||
const tEnc = await encryptTotpSecret(totp);
|
||||
totpEnc = tEnc.encrypted;
|
||||
totpIv = tEnc.iv;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/entries/' + soState.id, {
|
||||
method: 'PUT',
|
||||
@@ -1544,6 +1808,7 @@ async function soSave() {
|
||||
body: JSON.stringify({
|
||||
site, username: user,
|
||||
encrypted_password: enc.encrypted, iv: enc.iv,
|
||||
totp_secret: totpEnc, totp_iv: totpIv,
|
||||
folder: fold, tags: soState.tags.join(','),
|
||||
}),
|
||||
});
|
||||
@@ -1601,6 +1866,7 @@ function passwordField(plain) {
|
||||
}
|
||||
|
||||
function closeSlideOver() {
|
||||
stopTotpTick();
|
||||
$('#slideover').classList.remove('is-open');
|
||||
state.selectedId = null;
|
||||
renderGrid();
|
||||
|
||||
Reference in New Issue
Block a user