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:
@@ -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<string>('site', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
||||
LSite := Trim(LBody.GetValue<string>('site', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
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
|
||||
@@ -218,12 +242,14 @@ begin
|
||||
|
||||
LBody := TJSONHelper.ReadBody(ARequest);
|
||||
try
|
||||
LSite := Trim(LBody.GetValue<string>('site', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
||||
LSite := Trim(LBody.GetValue<string>('site', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user