feat(crypto): encrypt username at rest (CODE_AUDIT §1.3)

username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.

Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
  rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
  searches client-side anyway).

Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
  cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
  username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
  body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
  batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
  still carry cleartext username so the DB gets scrubbed (bumps updated_at
  once; plaintext unchanged so devices converge).

site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.

NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-08 22:04:48 +01:00
parent 2578ac0d06
commit 69fb2b10dd
8 changed files with 213 additions and 37 deletions
@@ -1040,6 +1040,7 @@ begin
' encrypted_password = :ep, iv = :iv, ' +
' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' username_enc = :uenc, username_iv = :uiv, ' +
' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid';
@@ -1053,6 +1054,8 @@ begin
LTotpIv := LEntry.GetValue<string>('totp_iv', '');
var LCf := LEntry.GetValue<string>('custom_fields', '');
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
var LUEnc := LEntry.GetValue<string>('username_enc', '');
var LUIv := LEntry.GetValue<string>('username_iv', '');
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
@@ -1074,6 +1077,12 @@ begin
else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear
else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('uenc').DataType := ftMemo;
LQ.ParamByName('uiv').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;
LQ.ExecSQL;
end;
// Password history is encrypted with the OLD vault key — we