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;
}
.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 -------------------------------------- */