feat: secure notes + password history + custom fields + quick-win bundle

Big feature trio
- Secure notes (kind='login'|'note') reusing the encrypted_password+iv
  pipeline for the body. New sidebar entry, slideover variant (title +
  multiline body), distinct card / table-view rendering, badge in name
  column, copy-content button replacing the password copy on note rows.
- Password history: entries_password_history table keeps up to 20 prior
  ciphertexts per entry. HandleUpdateEntry pushes the pre-update
  encrypted_password into history ONLY when it actually differs from
  the incoming one (JS reuses originalEncrypted bit-for-bit when the
  plaintext is unchanged — avoids spamming history on title/folder edits).
  GET /entries/{id}/history endpoint. Slideover modal lists versions
  with mask/reveal/copy/revert. Master-pw rotation wipes history (old
  ciphertext can't be decrypted with the new key).
- Custom fields: per-entry encrypted JSON array of {label, value,
  is_secret}. Same crypto pipeline as the password. Slideover row UI
  with label/value inputs, secret toggle (eye), copy, delete. Re-
  encryption flows through bulk-import, change-master-password, and
  duplicate.

Quick wins
- Cheatsheet overlay (press '?' or topbar button or Ctrl+K). Lists all
  hotkeys + global / tray / card actions. SVG icons inline so the
  cheatsheet matches the actual app glyphs (no emoji mismatch).
- Open URL button on entry cards: ShellExecute via cmd://app/open-url,
  http(s) only, validates entry.site looks like a real hostname.
- Trash auto-purge: setting "Empty trash after N days" (never/7/30/90).
  DELETE /entries/trash/old?days=N called at every unlock.

Favicon strategy
- Subdomains (chat.deepseek.com, app.X.com…) now try the SLD first
  (deepseek.com.ico) before the full host. DDG often returns a generic
  placeholder for subdomains that passes the byte threshold; the SLD-first
  switch surfaces the real brand icon.
- Cap bumped 64 KB → 256 KB on all three sides (Delphi fetch, server
  endpoint, JS upload). DDG sometimes serves the full-res asset.

UX polish
- Click-outside-slideover: stopPropagation everywhere it bites. Custom
  fields buttons (add / delete / secret toggle / copy / eye) all stop
  the click bubble so the document-level "close on outside click" handler
  doesn't fire when rerender() detaches the target from the DOM.
- Native search-cancel button restyled: cyan accent X via mask-image,
  cursor: pointer, breathing room before the Ctrl+K kbd chip.
- Password history modal: scrollable body, multiline wrapped passwords,
  hover border highlight.
- Cheatsheet panel widened (560 → 720 px) so the descriptions no longer
  ellipsis-clip.
- "+ New" topbar splits into a small dropdown: New login / New note.
- Notes show a "note" badge in table-view name column, italic
  "Encrypted note" placeholder in the username column.

Internals
- duplicateEntry copies kind + custom_fields too (one-line forgotten
  earlier).
- entries_password_history dropped on master-pw rotation — the old
  ciphertexts are unrecoverable with the new key.
- bulk-import re-encryption path includes custom_fields.

CLAUDE.md
- "Entry payload — call sites à toucher ensemble" lists the 6 spots
  to update when adding a new (en)crypted field. Notes the historical
  miss of kind in duplicateEntry and custom_fields in the rotation +
  duplicate.

Repo hygiene
- .gitattributes forces CRLF on Delphi sources (RAD Studio refuses LF).
  text=auto for web frontend / docs, binary for .res / .exe / images.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:17:19 +01:00
parent 39406d712e
commit 63fac5b3b7
11 changed files with 1487 additions and 113 deletions
+204 -10
View File
@@ -122,6 +122,21 @@ begin
LObj.AddPair('icon_b64', TJSONNull.Create)
else
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
// Entry kind. Legacy / unset → 'login'.
var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login';
LObj.AddPair('kind', LKindVal);
// Custom fields: opaque ciphertext + IV, treated identically to
// password / totp_secret. NULL → JSON null so the client can
// distinguish "never set" from "empty array stored".
if LQ.FieldByName('custom_fields').IsNull then
LObj.AddPair('custom_fields', TJSONNull.Create)
else
LObj.AddPair('custom_fields', LQ.FieldByName('custom_fields').AsString);
if LQ.FieldByName('custom_fields_iv').IsNull then
LObj.AddPair('custom_fields_iv', TJSONNull.Create)
else
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
LArr.Add(LObj);
@@ -143,7 +158,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
var
LUserId, LNewId: Integer;
LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv: string;
LQ: TFDQuery;
begin
try
@@ -165,13 +181,24 @@ begin
// TOTP secret + IV — optional. Empty string = no TOTP configured.
LTotpSec := LBody.GetValue<string>('totp_secret', '');
LTotpIv := LBody.GetValue<string>('totp_iv', '');
LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
finally
LBody.Free;
end;
if (LSite = '') or (LEnc = '') then
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
// 'login' entries require a site; 'note' only needs encrypted body.
if LEnc = '' then
begin
TJSONHelper.SendError(AResponse, 400, 'Site & password required');
TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit;
end;
if (LKind = 'login') and (LSite = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Site required');
Exit;
end;
@@ -185,8 +212,10 @@ begin
LQ.SQL.Text :=
'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' created_at, updated_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :c, :c2)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle;
@@ -211,6 +240,11 @@ begin
LQ.ParamByName('tiv').Clear
else
LQ.ParamByName('tiv').AsString := LTotpIv;
LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString;
LQ.ParamByName('cfiv').DataType := ftString;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL;
@@ -230,6 +264,7 @@ begin
LObj.AddPair('username', LUser);
LObj.AddPair('folder', LFolder);
LObj.AddPair('tags', LTags);
LObj.AddPair('kind', LKind);
TJSONHelper.SendJSON(AResponse, LObj);
end;
@@ -240,7 +275,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
var
LUserId, LId: Integer;
LBody: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv: string;
LQ: TFDQuery;
begin
try
@@ -268,13 +304,23 @@ begin
LTags := Trim(LBody.GetValue<string>('tags', ''));
LTotpSec := LBody.GetValue<string>('totp_secret', '');
LTotpIv := LBody.GetValue<string>('totp_iv', '');
LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
finally
LBody.Free;
end;
if (LSite = '') or (LEnc = '') then
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
if LEnc = '' then
begin
TJSONHelper.SendError(AResponse, 400, 'Site & password required');
TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit;
end;
if (LKind = 'login') and (LSite = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Site required');
Exit;
end;
@@ -284,10 +330,36 @@ begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Insert pre-update ciphertext into history ONLY when it actually
// changed (JS reuses originalEncrypted bit-for-bit otherwise).
LQ.SQL.Text :=
'INSERT INTO entries_password_history ' +
' (entry_id, user_id, encrypted_password, iv, kind, changed_at) ' +
'SELECT id, user_id, encrypted_password, iv, ' +
' COALESCE(NULLIF(kind, ''''), ''login''), :now ' +
'FROM vault_entries ' +
'WHERE id = :id AND user_id = :uid ' +
' AND encrypted_password <> :newenc';
LQ.ParamByName('now').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('newenc').AsString := LEnc;
LQ.ExecSQL;
// Cap to last 20 versions.
LQ.SQL.Text :=
'DELETE FROM entries_password_history WHERE id IN (' +
' SELECT id FROM entries_password_history ' +
' WHERE entry_id = :id ' +
' ORDER BY changed_at DESC ' +
' LIMIT -1 OFFSET 20)';
LQ.ParamByName('id').AsInteger := LId;
LQ.ExecSQL;
LQ.SQL.Text :=
'UPDATE vault_entries ' +
'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' +
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' +
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
' updated_at=:c ' +
'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('s').AsString := LSite;
@@ -311,6 +383,11 @@ begin
LQ.ParamByName('tiv').Clear
else
LQ.ParamByName('tiv').AsString := LTotpIv;
LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString;
LQ.ParamByName('cfiv').DataType := ftString;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
@@ -505,7 +582,10 @@ begin
// Soft cap to prevent a misbehaving fetcher from ballooning the DB.
// 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64.
if Length(LIcon) > 65536 then
// Soft cap. Most favicons are < 10 KB; bumped to 256 KB because DDG
// occasionally serves the brand's full-resolution PNG (deepseek.com
// came back at 200+ KB) and we want those to be cacheable too.
if Length(LIcon) > 262144 then
begin
TJSONHelper.SendError(AResponse, 413, 'Icon too large');
Exit;
@@ -571,6 +651,118 @@ begin
TJSONHelper.SendOK(AResponse, 'Icons cleared');
end;
// ===== GET /entries/{id}/history =============================================
// Returns up to 20 prior versions of one entry's encrypted_password+iv.
// The client decrypts with the current vault key (rotation re-encrypts the
// whole history table, see HandleChangeMasterPassword in PM.Handler.Auth).
procedure HandleGetEntryHistory(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LId := StrToIntDef(AParams[0], 0);
if LId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid id');
Exit;
end;
LArr := TJSONArray.Create;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT id, encrypted_password, iv, kind, changed_at ' +
'FROM entries_password_history ' +
'WHERE entry_id = :id AND user_id = :uid ' +
'ORDER BY changed_at DESC';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
LObj.AddPair('kind', LQ.FieldByName('kind').AsString);
LObj.AddPair('changed_at', ISODateTimeField(LQ.FieldByName('changed_at')));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== DELETE /entries/trash/old?days=N ======================================
// Permanently deletes trashed entries whose deleted_at is older than N days.
// Driven by the user's "Auto-purge trash" setting; called from JS at login.
procedure HandleAutoPurgeTrash(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LDays, LPurged: Integer;
LQ: TFDQuery;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LDays := StrToIntDef(GetQueryParam(ARequest, 'days', '0'), 0);
if (LDays <= 0) or (LDays > 3650) then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid days');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'DELETE FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 ' +
' AND deleted_at IS NOT NULL ' +
' AND (julianday(''now'') - julianday(deleted_at)) >= :d';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('d').AsInteger := LDays;
LQ.ExecSQL;
LPurged := LQ.RowsAffected;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
if LPurged > 0 then
LogAudit(LUserId, Format('auto_purge_trash %d entries (> %d days)',
[LPurged, LDays]), GetClientIP(ARequest));
LObj := TJSONObject.Create;
LObj.AddPair('purged', TJSONNumber.Create(LPurged));
TJSONHelper.SendJSON(AResponse, LObj);
end;
// ===== DELETE /entries/trash/empty ===========================================
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
@@ -771,11 +963,13 @@ initialization
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon);
Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory);
Router.Register('GET', '/entries/count', HandleEntriesCount);
Router.Register('GET', '/entries', HandleGetEntries);
Router.Register('POST', '/entries', HandleCreateEntry);