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
+11
View File
@@ -361,6 +361,17 @@ begin
// value, so no server-side change to those. NULL = not yet encrypted.
AddColumnIfMissing('vault_entries', 'username_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'username_iv', 'TEXT');
// Same metadata-at-rest treatment for site / title / tags (§1.3). Cleartext
// columns phased out the same way as username: new writes store '' there and
// the ciphertext here; the client sweep migrates old rows; search/sort stay
// client-side on the decrypted in-memory values. The server no longer
// validates "site required" (can't read the ciphertext) — the client does.
AddColumnIfMissing('vault_entries', 'site_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'site_iv', 'TEXT');
AddColumnIfMissing('vault_entries', 'title_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'title_iv', 'TEXT');
AddColumnIfMissing('vault_entries', 'tags_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'tags_iv', 'TEXT');
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
// Fetched on demand by the Delphi favicon proxy when the user opts in.
// NULL = no icon cached → JS falls back to the first-letter avatar.