feat(crypto): encrypt site/title/tags at rest too (CODE_AUDIT §1.3)

Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.

Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
  cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
  cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.

Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
  AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
  rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
  client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
  frontend never sends ?search=).

Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.

username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-09 11:25:13 +01:00
parent 263799adcd
commit 6556ce8dea
8 changed files with 278 additions and 136 deletions
@@ -1041,6 +1041,9 @@ begin
' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' username_enc = :uenc, username_iv = :uiv, ' +
' site_enc = :senc, site_iv = :siv, ' +
' title_enc = :tenc, title_iv = :tiv2, ' +
' tags_enc = :genc, tags_iv = :giv, ' +
' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid';
@@ -1056,6 +1059,12 @@ begin
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
var LUEnc := LEntry.GetValue<string>('username_enc', '');
var LUIv := LEntry.GetValue<string>('username_iv', '');
var LSEnc := LEntry.GetValue<string>('site_enc', '');
var LSIv := LEntry.GetValue<string>('site_iv', '');
var LTEnc := LEntry.GetValue<string>('title_enc', '');
var LTIv := LEntry.GetValue<string>('title_iv', '');
var LGEnc := LEntry.GetValue<string>('tags_enc', '');
var LGIv := LEntry.GetValue<string>('tags_iv', '');
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
@@ -1079,10 +1088,28 @@ begin
else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('uenc').DataType := ftMemo;
LQ.ParamByName('uiv').DataType := ftMemo;
LQ.ParamByName('senc').DataType := ftMemo;
LQ.ParamByName('siv').DataType := ftMemo;
LQ.ParamByName('tenc').DataType := ftMemo;
LQ.ParamByName('tiv2').DataType := ftMemo;
LQ.ParamByName('genc').DataType := ftMemo;
LQ.ParamByName('giv').DataType := ftMemo;
if LUEnc = '' then LQ.ParamByName('uenc').Clear
else LQ.ParamByName('uenc').Value := LUEnc;
if LUIv = '' then LQ.ParamByName('uiv').Clear
else LQ.ParamByName('uiv').Value := LUIv;
if LSEnc = '' then LQ.ParamByName('senc').Clear
else LQ.ParamByName('senc').Value := LSEnc;
if LSIv = '' then LQ.ParamByName('siv').Clear
else LQ.ParamByName('siv').Value := LSIv;
if LTEnc = '' then LQ.ParamByName('tenc').Clear
else LQ.ParamByName('tenc').Value := LTEnc;
if LTIv = '' then LQ.ParamByName('tiv2').Clear
else LQ.ParamByName('tiv2').Value := LTIv;
if LGEnc = '' then LQ.ParamByName('genc').Clear
else LQ.ParamByName('genc').Value := LGEnc;
if LGIv = '' then LQ.ParamByName('giv').Clear
else LQ.ParamByName('giv').Value := LGIv;
LQ.ExecSQL;
end;
// Password history is encrypted with the OLD vault key — we