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:
2026-05-23 05:13:50 +01:00
parent a45897c33d
commit cf94f67488
4 changed files with 375 additions and 21 deletions
+45
View File
@@ -912,6 +912,51 @@ input[type="range"]::-webkit-slider-thumb {
font-weight: 600; font-weight: 600;
} }
.entry-chip.is-pwned svg { stroke: #fff; } .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 -------------------------------------- */ /* ---- 10. SLIDE-OVER -------------------------------------- */
+42 -5
View File
@@ -103,6 +103,17 @@ begin
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger)); LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger));
LObj.AddPair('tags', LQ.FieldByName('tags').AsString); 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('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
LArr.Add(LObj); LArr.Add(LObj);
@@ -124,7 +135,7 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LNewId: Integer; LUserId, LNewId: Integer;
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -142,6 +153,9 @@ begin
LEnc := LBody.GetValue<string>('encrypted_password', ''); LEnc := LBody.GetValue<string>('encrypted_password', '');
LIV := LBody.GetValue<string>('iv', ''); LIV := LBody.GetValue<string>('iv', '');
LTags := Trim(LBody.GetValue<string>('tags', '')); 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 finally
LBody.Free; LBody.Free;
end; end;
@@ -162,8 +176,8 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, created_at, updated_at) ' + ' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :c, :c2)'; 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
@@ -171,6 +185,16 @@ begin
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
LQ.ParamByName('t').AsString := LTags; 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('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
@@ -199,7 +223,7 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -224,6 +248,8 @@ begin
LEnc := LBody.GetValue<string>('encrypted_password', ''); LEnc := LBody.GetValue<string>('encrypted_password', '');
LIV := LBody.GetValue<string>('iv', ''); LIV := LBody.GetValue<string>('iv', '');
LTags := Trim(LBody.GetValue<string>('tags', '')); LTags := Trim(LBody.GetValue<string>('tags', ''));
LTotpSec := LBody.GetValue<string>('totp_secret', '');
LTotpIv := LBody.GetValue<string>('totp_iv', '');
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -243,7 +269,8 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' + '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'; 'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
@@ -251,6 +278,16 @@ begin
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
LQ.ParamByName('t').AsString := LTags; 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('c').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
+6
View File
@@ -215,6 +215,12 @@ begin
// UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa"). // UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa").
// Simple format, search via LIKE %tag%. Frontend handles parsing/joining. // Simple format, search via LIKE %tag%. Frontend handles parsing/joining.
AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT '''''); 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'''); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2''');
// PBKDF2 iteration count per user. Legacy rows (predating this column) // PBKDF2 iteration count per user. Legacy rows (predating this column)
// default to 100000 — the value used by api.php / the early Delphi build. // default to 100000 — the value used by api.php / the early Delphi build.
+266
View File
@@ -177,6 +177,99 @@ async function api(path, opts) {
return body; 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) // HIBP — Have I Been Pwned breach check (k-anonymity)
// ============================================================ // ============================================================
@@ -1042,6 +1135,18 @@ function renderCard(e) {
chip.appendChild(el('span', null, 'Pwned')); chip.appendChild(el('span', null, 'Pwned'));
meta.appendChild(chip); 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); card.appendChild(meta);
return card; return card;
@@ -1355,21 +1460,35 @@ async function openSlideOver(id) {
const plain = await decryptPwd(e.encrypted_password, e.iv); 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" // Track original values so we can detect "dirty"
soState = { soState = {
id: e.id, id: e.id,
original: { original: {
site: e.site, username: e.username || '', password: plain, site: e.site, username: e.username || '', password: plain,
folder: e.folder || 'All', tags: parseTags(e.tags).join(','), folder: e.folder || 'All', tags: parseTags(e.tags).join(','),
totp: plainTotp,
}, },
tags: parseTags(e.tags), tags: parseTags(e.tags),
originalEncrypted: e.encrypted_password, originalEncrypted: e.encrypted_password,
originalIV: e.iv, originalIV: e.iv,
originalTotpEncrypted: e.totp_secret,
originalTotpIV: e.totp_iv,
}; };
body.appendChild(soEditableField('Site', 'soSite', e.site)); body.appendChild(soEditableField('Site', 'soSite', e.site));
body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); body.appendChild(soEditableField('Username', 'soUsername', e.username || ''));
body.appendChild(soPasswordField(plain)); body.appendChild(soPasswordField(plain));
body.appendChild(soTotpField(plainTotp));
body.appendChild(soFolderField(e.folder || 'All')); body.appendChild(soFolderField(e.folder || 'All'));
body.appendChild(soTagsField()); body.appendChild(soTagsField());
@@ -1441,6 +1560,127 @@ function soPasswordField(plain) {
return wrap; 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) { function soFolderField(current) {
const wrap = el('div', { class: 'slideover-field' }); const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
@@ -1510,6 +1750,7 @@ function soDirtyCheck() {
username: ($('#soUsername') || {}).value || '', username: ($('#soUsername') || {}).value || '',
password: ($('#soPassword') || {}).value || '', password: ($('#soPassword') || {}).value || '',
folder: ($('#soFolder') || {}).value || '', folder: ($('#soFolder') || {}).value || '',
totp: ($('#soTotpSecret') || {}).value || '',
tags: soState.tags.join(','), tags: soState.tags.join(','),
}; };
const dirty = const dirty =
@@ -1517,6 +1758,7 @@ function soDirtyCheck() {
cur.username !== soState.original.username || cur.username !== soState.original.username ||
cur.password !== soState.original.password || cur.password !== soState.original.password ||
cur.folder !== soState.original.folder || cur.folder !== soState.original.folder ||
cur.totp !== soState.original.totp ||
cur.tags !== soState.original.tags; cur.tags !== soState.original.tags;
const btn = $('#soSaveBtn'); const btn = $('#soSaveBtn');
if (btn) btn.style.display = dirty ? '' : 'none'; if (btn) btn.style.display = dirty ? '' : 'none';
@@ -1528,6 +1770,7 @@ async function soSave() {
const user = $('#soUsername').value.trim(); const user = $('#soUsername').value.trim();
const pwd = $('#soPassword').value; const pwd = $('#soPassword').value;
const fold = $('#soFolder').value; const fold = $('#soFolder').value;
const totp = (($('#soTotpSecret') || {}).value || '').trim();
if (!site || !pwd) return toast('Site and password required', 'error'); if (!site || !pwd) return toast('Site and password required', 'error');
// Only re-encrypt if password changed; otherwise reuse stored ciphertext // Only re-encrypt if password changed; otherwise reuse stored ciphertext
@@ -1537,6 +1780,27 @@ async function soSave() {
} else { } else {
enc = await encryptPwd(pwd); 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 { try {
await api('/entries/' + soState.id, { await api('/entries/' + soState.id, {
method: 'PUT', method: 'PUT',
@@ -1544,6 +1808,7 @@ async function soSave() {
body: JSON.stringify({ body: JSON.stringify({
site, username: user, site, username: user,
encrypted_password: enc.encrypted, iv: enc.iv, encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
folder: fold, tags: soState.tags.join(','), folder: fold, tags: soState.tags.join(','),
}), }),
}); });
@@ -1601,6 +1866,7 @@ function passwordField(plain) {
} }
function closeSlideOver() { function closeSlideOver() {
stopTotpTick();
$('#slideover').classList.remove('is-open'); $('#slideover').classList.remove('is-open');
state.selectedId = null; state.selectedId = null;
renderGrid(); renderGrid();