From cf94f67488953ff72d09b211503e66ebf40e9fb6 Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Sat, 23 May 2026 05:13:50 +0100 Subject: [PATCH] feat(2fa): TOTP secret storage + live 6-digit code generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- css/style.css | 45 +++ .../Handlers/PM.Handler.Entries.pas | 71 +++-- delphi-backend/Source/PM.Database.pas | 6 + js/app.js | 274 +++++++++++++++++- 4 files changed, 375 insertions(+), 21 deletions(-) diff --git a/css/style.css b/css/style.css index d2fee8e..4a7cf98 100644 --- a/css/style.css +++ b/css/style.css @@ -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 -------------------------------------- */ diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index ffff9e8..d720434 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -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 @@ -136,12 +147,15 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LSite := Trim(LBody.GetValue('site', '')); - LUser := Trim(LBody.GetValue('username', '')); - LFolder := Trim(LBody.GetValue('folder', 'All')); - LEnc := LBody.GetValue('encrypted_password', ''); - LIV := LBody.GetValue('iv', ''); - LTags := Trim(LBody.GetValue('tags', '')); + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + // TOTP secret + IV — optional. Empty string = no TOTP configured. + LTotpSec := LBody.GetValue('totp_secret', ''); + LTotpIv := LBody.GetValue('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 @@ -218,12 +242,14 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LSite := Trim(LBody.GetValue('site', '')); - LUser := Trim(LBody.GetValue('username', '')); - LFolder := Trim(LBody.GetValue('folder', 'All')); - LEnc := LBody.GetValue('encrypted_password', ''); - LIV := LBody.GetValue('iv', ''); - LTags := Trim(LBody.GetValue('tags', '')); + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + LTotpSec := LBody.GetValue('totp_secret', ''); + LTotpIv := LBody.GetValue('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; diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 119d934..a9d5f07 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -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. diff --git a/js/app.js b/js/app.js index cc615d9..cc9589d 100644 --- a/js/app.js +++ b/js/app.js @@ -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')); @@ -1506,10 +1746,11 @@ function renderSoChips() { function soDirtyCheck() { if (!soState) return; const cur = { - site: ($('#soSite') || {}).value || '', - username: ($('#soUsername') || {}).value || '', - password: ($('#soPassword') || {}).value || '', - folder: ($('#soFolder') || {}).value || '', + site: ($('#soSite') || {}).value || '', + 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();