From 6556ce8dea36e3bd3c8a37c64bd4f3eb44c67b8b Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:25:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20encrypt=20site/title/tags=20at?= =?UTF-8?q?=20rest=20too=20(CODE=5FAUDIT=20=C2=A71.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the username-at-rest scheme to site, title and tags — the last searchable metadata still stored cleartext. Same design: dedicated _enc/_iv columns (AES-GCM under the vault key), decrypted at load into e., 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 --- CLAUDE.md | 40 +++-- CODE_AUDIT.md | 23 +-- delphi-backend/Handlers/PM.Handler.Auth.pas | 27 +++ .../Handlers/PM.Handler.Entries.pas | 161 ++++++++++++------ delphi-backend/Source/PM.Database.pas | 11 ++ js/app.import.js | 2 +- js/app.js | 134 +++++++++------ js/tests/merge.test.js | 16 +- 8 files changed, 278 insertions(+), 136 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a49076..6a29a71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -429,26 +429,32 @@ Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** : `encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`, -**`username_enc/username_iv`** (métadonnée-at-rest, cf. plus bas), -plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title, -folder, tags, kind, template`). `template` est le sous-type +**`username_enc/iv`, `site_enc/iv`, `title_enc/iv`, `tags_enc/iv`** +(métadonnées-at-rest, cf. plus bas), plus le champ-icône `icon_b64` et les +méta non chiffrées (`folder, kind, template`). `template` est le sous-type (ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le label de card/table — vide pour login/note génériques. -**`username` chiffré (§1.3)** : la colonne `username` en clair est en voie -d'extinction — les nouvelles écritures y mettent `''` et rangent le chiffré -dans `username_enc/username_iv` (AES-GCM sous la clé du vault, comme -`encrypted_password`). `loadEntries`/`loadTrash` déchiffrent → `e.username` -en mémoire, donc **recherche/tri/render/autofill-match marchent inchangés** -(tout est déjà côté client). Choke-point d'écriture : `withEncryptedUsername(obj)` -(chiffre `obj.username`, blanchit le clair) — enveloppe **chaque** body -POST/PUT `/entries` (saveEntry, soSave, duplicateEntry, moveEntryToFolder, -addTagToEntry, batchMove/AddTag, encryptImportEntry, migration). Anciennes -lignes migrées au unlock par `migrateUsernamesAtRest` (PUT re-ship, bump -`updated_at` assumé une fois). Rotation master-pw re-chiffre `username_enc` -sous la nouvelle clé (JS loop + UPDATE serveur). Le `?q=` serveur ne LIKE -plus que `site`. `site`/`title`/`tags` restent en clair (à chiffrer plus -tard, même patron — cf. [[encrypt-metadata-plan]]). +**Métadonnées chiffrées (§1.3)** : `username`, `site`, `title`, `tags` sont +chiffrés au repos (colonnes `_enc/_iv`, AES-GCM sous la clé du vault +comme `encrypted_password`). Les colonnes en clair reçoivent `''` sur écriture. +`loadEntries`/`loadTrash` déchiffrent → `e.` en mémoire, donc +**recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est +déjà côté client). Liste des champs : `ENCRYPTED_META_FIELDS = +['username','site','title','tags']`. Choke-point d'écriture : +`withEncryptedMeta(obj)` (chiffre chaque champ, blanchit le clair) — enveloppe +**chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry, +moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry, +migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au +unlock par `migrateMetadataAtRest` (PUT re-ship, y compris corbeille, bump +`updated_at` assumé une fois). Rotation master-pw re-chiffre les 4 champs sous +la nouvelle clé (JS loop `ENCRYPTED_META_FIELDS` + UPDATE serveur). +**Le `?q=` serveur est neutralisé** (site+username chiffrés → LIKE inutile ; +le front cherche côté client). **La validation « Site required » serveur est +retirée** (site='' quand chiffré) — le client la fait. `folder` reste en clair +(requête serveur de réassignation sur delete-folder). Reste en clair : +`folder`, `kind`, `template`, métadonnées d'attachments, nombre de lignes, +timestamps. Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours** mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index d1572c5..917730e 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -85,20 +85,21 @@ Documenté mais à rappeler pour un futur modèle de menace : - `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés** - `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé) -- `vault_entries` : ~~`username`~~ **chiffré (2026-07-08)** ; `site`, `title`, - `folder`, `tags`, `kind`, `template` encore en clair. +- `vault_entries` : ~~`username`, `site`, `title`, `tags`~~ **chiffrés + (2026-07-09)** ; `folder`, `kind`, `template` encore en clair. -**`username` chiffré au repos (✅ 2026-07-08)** : colonnes -`username_enc/username_iv` (AES-GCM sous la clé du vault). Clé de l'approche : +**`username` + `site` + `title` + `tags` chiffrés au repos (✅ 2026-07-09)** : +colonnes `_enc/_iv` (AES-GCM sous la clé du vault). Clé de l'approche : recherche/tri sont **côté client** → on déchiffre au `loadEntries` en mémoire, donc AES-GCM plein (IV aléatoire), pas de searchable-encryption. Choke-point -`withEncryptedUsername` sur tous les writes ; migration `migrateUsernamesAtRest` -au unlock pour les vieilles lignes ; rotation re-chiffre. Détails dans -CLAUDE.md « Entry payload ». **Reste** : `site`/`title`/`tags` (même patron, -[[encrypt-metadata-plan]]). Résiduel : nombre de lignes, timestamps, métadonnées -d'attachments. **✅ Validé runtime (2026-07-09)** : après rebuild + unlock, la -base montre 0 username en clair (54 entries, 43 `username_enc`, migration -`migrateUsernamesAtRest` complétée) et l'affichage/recherche marchent. +`withEncryptedMeta` sur tous les writes ; `decryptEntryMeta` au load ; migration +`migrateMetadataAtRest` (live + corbeille) ; rotation re-chiffre les 4. +Validation serveur « Site required » retirée + `?q=` neutralisé (LIKE inutile +sur ciphertext). Détails CLAUDE.md « Entry payload ». **`username` validé +runtime le 2026-07-09** (0 en clair après migration). **`site`/`title`/`tags` +NON encore compilés/testés runtime** — même patron, gros changement +multi-handlers, rebuild + test soigneux requis. Résiduel : `folder`, `kind`, +`template`, métadonnées d'attachments, nombre de lignes, timestamps. ### 1.4 🟡 Snapshot de sync = tout le vault en clair sous le sync password diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index ffb831b..cafac35 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -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('custom_fields_iv', ''); var LUEnc := LEntry.GetValue('username_enc', ''); var LUIv := LEntry.GetValue('username_iv', ''); + var LSEnc := LEntry.GetValue('site_enc', ''); + var LSIv := LEntry.GetValue('site_iv', ''); + var LTEnc := LEntry.GetValue('title_enc', ''); + var LTIv := LEntry.GetValue('title_iv', ''); + var LGEnc := LEntry.GetValue('tags_enc', ''); + var LGIv := LEntry.GetValue('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 diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index e182319..af612e9 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -55,6 +55,26 @@ begin Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime); end; +// Emit a TEXT field as a JSON string, or JSON null when the column is NULL. +// Used for the *_enc/*_iv encrypted-metadata columns so the client can tell +// "not migrated yet" (null) from "encrypted, empty plaintext" (a string). +procedure AddNullableField(AObj: TJSONObject; const AName: string; AField: TField); +begin + if AField.IsNull then + AObj.AddPair(AName, TJSONNull.Create) + else + AObj.AddPair(AName, AField.AsString); +end; + +// Bind a TEXT param as NULL when empty, else the value (ftMemo so long +// ciphertext isn't truncated). For the encrypted-metadata *_enc/*_iv params. +procedure BindNullable(AQ: TFDQuery; const AParam, AValue: string); +begin + AQ.ParamByName(AParam).DataType := ftMemo; + if AValue = '' then AQ.ParamByName(AParam).Clear + else AQ.ParamByName(AParam).Value := AValue; +end; + // ===== GET /entries ========================================================== procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo; @@ -83,24 +103,14 @@ begin LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; - if LSearch <> '' then - begin - LQ.SQL.Text := - 'SELECT * FROM vault_entries ' + - 'WHERE user_id = :uid AND deleted = :del ' + - // username is encrypted at rest → LIKE can't match it; the frontend - // searches client-side on the decrypted vault anyway. Site only. - 'AND site LIKE :q ' + - 'ORDER BY updated_at DESC'; - LQ.ParamByName('q').AsString := '%' + LSearch + '%'; - end - else - begin - LQ.SQL.Text := - 'SELECT * FROM vault_entries ' + - 'WHERE user_id = :uid AND deleted = :del ' + - 'ORDER BY updated_at DESC'; - end; + // The ?search= query param is now ignored server-side: site AND username + // are both encrypted at rest, so a SQL LIKE can't match them. The + // frontend loads the whole (decrypted) vault and filters client-side — + // it never sends ?search=. Kept LSearch read for API back-compat only. + LQ.SQL.Text := + 'SELECT * FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = :del ' + + 'ORDER BY updated_at DESC'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('del').AsInteger := LDeleted; LQ.Open; @@ -124,6 +134,14 @@ begin LObj.AddPair('username_iv', TJSONNull.Create) else LObj.AddPair('username_iv', LQ.FieldByName('username_iv').AsString); + // Encrypted site / title / tags — same scheme as username_enc. NULL → + // JSON null so the client falls back to the cleartext siblings above. + AddNullableField(LObj, 'site_enc', LQ.FieldByName('site_enc')); + AddNullableField(LObj, 'site_iv', LQ.FieldByName('site_iv')); + AddNullableField(LObj, 'title_enc', LQ.FieldByName('title_enc')); + AddNullableField(LObj, 'title_iv', LQ.FieldByName('title_iv')); + AddNullableField(LObj, 'tags_enc', LQ.FieldByName('tags_enc')); + AddNullableField(LObj, 'tags_iv', LQ.FieldByName('tags_iv')); LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString); LObj.AddPair('iv', LQ.FieldByName('iv').AsString); LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString); @@ -316,7 +334,8 @@ var LUserId, LNewId: Integer; LBody, LObj: TJSONObject; LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, - LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; + LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid, + LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LQ: TFDQuery; begin try @@ -336,6 +355,14 @@ begin // in username_enc/username_iv instead. LUserEnc := LBody.GetValue('username_enc', ''); LUserIv := LBody.GetValue('username_iv', ''); + // Encrypted site / title / tags — same scheme. Cleartext siblings are '' + // when these are present. + LSiteEnc := LBody.GetValue('site_enc', ''); + LSiteIv := LBody.GetValue('site_iv', ''); + LTitleEnc:= LBody.GetValue('title_enc', ''); + LTitleIv := LBody.GetValue('title_iv', ''); + LTagsEnc := LBody.GetValue('tags_enc', ''); + LTagsIv := LBody.GetValue('tags_iv', ''); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); LIV := LBody.GetValue('iv', ''); @@ -358,17 +385,15 @@ begin 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, 'Content required'); Exit; end; - if (LKind = 'login') and (LSite = '') then - begin - TJSONHelper.SendError(AResponse, 400, 'Site required'); - Exit; - end; + // NOTE: the old "Site required" check is gone — site is now encrypted at + // rest (LSite is '' when the client sent site_enc), so the server can't + // read it. The client already enforces "site + password required" before + // saving a login. LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2) @@ -380,21 +405,27 @@ begin LQ.SQL.Text := 'INSERT INTO vault_entries ' + '(user_id, site, title, username, username_enc, username_iv, ' + + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' + - 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + + 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + + ' :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; - // Encrypted username: NULL when not supplied (pre-migration client or a - // row that genuinely has no username) so GET emits JSON null. - LQ.ParamByName('uenc').DataType := ftMemo; - LQ.ParamByName('uiv').DataType := ftMemo; - if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; - if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; + // Encrypted metadata: NULL when not supplied (pre-migration client or a + // row with no value) so GET emits JSON null and the client falls back. + BindNullable(LQ, 'uenc', LUserEnc); + BindNullable(LQ, 'uiv', LUserIv); + BindNullable(LQ, 'senc', LSiteEnc); + BindNullable(LQ, 'siv', LSiteIv); + BindNullable(LQ, 'tenc', LTitleEnc); + BindNullable(LQ, 'tiv2', LTitleIv); + BindNullable(LQ, 'genc', LTagsEnc); + BindNullable(LQ, 'giv', LTagsIv); LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('f').AsString := LFolder; @@ -467,7 +498,8 @@ var LUserId, LId: Integer; LBody: TJSONObject; LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, - LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate: string; + LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate, + LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LHasTemplate: Boolean; LQ: TFDQuery; begin @@ -492,6 +524,12 @@ begin LUser := Trim(LBody.GetValue('username', '')); LUserEnc := LBody.GetValue('username_enc', ''); LUserIv := LBody.GetValue('username_iv', ''); + LSiteEnc := LBody.GetValue('site_enc', ''); + LSiteIv := LBody.GetValue('site_iv', ''); + LTitleEnc:= LBody.GetValue('title_enc', ''); + LTitleIv := LBody.GetValue('title_iv', ''); + LTagsEnc := LBody.GetValue('tags_enc', ''); + LTagsIv := LBody.GetValue('tags_iv', ''); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); LIV := LBody.GetValue('iv', ''); @@ -516,11 +554,8 @@ begin TJSONHelper.SendError(AResponse, 400, 'Content required'); Exit; end; - if (LKind = 'login') and (LSite = '') then - begin - TJSONHelper.SendError(AResponse, 400, 'Site required'); - Exit; - end; + // "Site required" removed — site is encrypted at rest (LSite is '' when the + // client sent site_enc). The client enforces it before saving. LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2) DB.Lock; @@ -562,6 +597,8 @@ begin LQ.SQL.Text := 'UPDATE vault_entries ' + 'SET site=:s, title=:tt, username=:u, username_enc=:uenc, username_iv=:uiv, ' + + ' site_enc=:senc, site_iv=:siv, title_enc=:tenc, title_iv=:tiv2, ' + + ' tags_enc=:genc, tags_iv=:giv, ' + ' encrypted_password=:e, iv=:i, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' + ' custom_fields=:cf, custom_fields_iv=:cfiv, ' + @@ -573,10 +610,14 @@ begin LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; - LQ.ParamByName('uenc').DataType := ftMemo; - LQ.ParamByName('uiv').DataType := ftMemo; - if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; - if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; + BindNullable(LQ, 'uenc', LUserEnc); + BindNullable(LQ, 'uiv', LUserIv); + BindNullable(LQ, 'senc', LSiteEnc); + BindNullable(LQ, 'siv', LSiteIv); + BindNullable(LQ, 'tenc', LTitleEnc); + BindNullable(LQ, 'tiv2', LTitleIv); + BindNullable(LQ, 'genc', LTagsEnc); + BindNullable(LQ, 'giv', LTagsIv); LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('f').AsString := LFolder; @@ -1148,7 +1189,8 @@ var LBody, LObj, LEntry: TJSONObject; LArr, LIds: TJSONArray; LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, - LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; + LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid, + LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LQ, LTomb: TFDQuery; begin try @@ -1202,10 +1244,12 @@ begin LQ.SQL.Text := 'INSERT INTO vault_entries ' + '(user_id, site, title, username, username_enc, username_iv, ' + + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' icon_b64, template, uuid, created_at, updated_at) ' + - 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + + 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + + ' :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)'; // Declare optional param types ONCE — the prepared statement is // reused across every imported entry, and FireDAC needs the @@ -1223,6 +1267,12 @@ begin LQ.ParamByName('tpl').DataType := ftString; 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; for I := 0 to LArr.Count - 1 do begin @@ -1232,6 +1282,12 @@ begin LUser := Trim(LEntry.GetValue('username', '')); LUserEnc := LEntry.GetValue('username_enc', ''); LUserIv := LEntry.GetValue('username_iv', ''); + LSiteEnc := LEntry.GetValue('site_enc', ''); + LSiteIv := LEntry.GetValue('site_iv', ''); + LTitleEnc:= LEntry.GetValue('title_enc', ''); + LTitleIv := LEntry.GetValue('title_iv', ''); + LTagsEnc := LEntry.GetValue('tags_enc', ''); + LTagsIv := LEntry.GetValue('tags_iv', ''); LFolder := Trim(LEntry.GetValue('folder', 'All')); LEnc := LEntry.GetValue('encrypted_password', ''); LIV := LEntry.GetValue('iv', ''); @@ -1256,18 +1312,21 @@ begin LIds.AddElement(TJSONNumber.Create(-1)); Continue; end; - if (LKind = 'login') and (LSite = '') then - begin - LIds.AddElement(TJSONNumber.Create(-1)); - Continue; - end; + // No "site required" skip — site is encrypted (LSite = '' when the + // row carries site_enc); the client validated before import. LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; - if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; - if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; + BindNullable(LQ, 'uenc', LUserEnc); + BindNullable(LQ, 'uiv', LUserIv); + BindNullable(LQ, 'senc', LSiteEnc); + BindNullable(LQ, 'siv', LSiteIv); + BindNullable(LQ, 'tenc', LTitleEnc); + BindNullable(LQ, 'tiv2', LTitleIv); + BindNullable(LQ, 'genc', LTagsEnc); + BindNullable(LQ, 'giv', LTagsIv); LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('f').AsString := LFolder; diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 59977b7..f1dce6e 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -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. diff --git a/js/app.import.js b/js/app.import.js index d6b8e5b..cfbe880 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -456,7 +456,7 @@ async function encryptImportEntry(plain) { : (plain.tags || ''); // Encrypt the username at rest (username_enc/username_iv, cleartext blanked) // via the shared choke point in app.js — same as the interactive save path. - return await withEncryptedUsername({ + return await withEncryptedMeta({ uuid: plain.uuid || '', site: plain.site, title: plain.title || '', diff --git a/js/app.js b/js/app.js index 04836aa..840618c 100644 --- a/js/app.js +++ b/js/app.js @@ -1468,61 +1468,82 @@ function folderMetaFor(name) { return { color: (f && f.color) || '', icon: (f && f.icon) || '' }; } -// Metadata-at-rest: username is stored encrypted (username_enc/username_iv). -// Decrypt it into e.username in place so all downstream code (render, search, -// autofill-match, sort) works on the plaintext transparently — exactly as -// when username was a cleartext column. Rows not yet migrated have no -// username_enc → their cleartext e.username is kept as-is (fallback). -async function decryptEntryUsernames(list) { +// Metadata-at-rest: username/site/title/tags are stored encrypted +// (_enc/_iv). Decrypt each into e. in place so all downstream code +// (render, search, autofill-match, sort, favicon) works on the plaintext +// transparently — exactly as when they were cleartext columns. Rows not yet +// migrated have no _enc → their cleartext e. is kept (fallback). +// (ENCRYPTED_META_FIELDS is declared just below, resolved at call time.) +async function decryptEntryMeta(list) { for (const e of (list || [])) { - if (e && e.username_enc && e.username_iv) { - const u = await decryptPwd(e.username_enc, e.username_iv); - if (u !== '[ERROR]') e.username = u; + if (!e) continue; + for (const f of ENCRYPTED_META_FIELDS) { + const enc = e[f + '_enc'], iv = e[f + '_iv']; + if (enc && iv) { + const v = await decryptPwd(enc, iv); + if (v !== '[ERROR]') e[f] = v; + } } } } -// Choke point for the write path: take an entry body object whose `username` -// holds PLAINTEXT, encrypt it into username_enc/username_iv, and blank the -// cleartext field so nothing readable is persisted. Wrap every POST/PUT -// /entries body in this. Empty username → all cleared (server stores NULL -// ciphertext + '' username). Mutates + returns the object for convenience. -async function withEncryptedUsername(obj) { - const plain = (obj && obj.username) || ''; - if (plain) { - const c = await encryptPwd(plain); - obj.username_enc = c.encrypted; - obj.username_iv = c.iv; - } else { - obj.username_enc = ''; - obj.username_iv = ''; +// Fields encrypted at rest as metadata (§1.3). Each `f` has cleartext `f` +// (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run +// client-side on the decrypted in-memory value, so encrypting these is +// transparent. `folder` stays cleartext (server folder-reassign query). +const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags']; + +// Choke point for the write path: take an entry body object whose metadata +// fields hold PLAINTEXT, encrypt each into _enc/_iv, and blank the +// cleartext so nothing readable is persisted. Wrap every POST/PUT /entries +// body in this. Empty field → cleared (server stores NULL ciphertext + ''). +// Mutates + returns the object for convenience. +async function withEncryptedMeta(obj) { + if (!obj) return obj; + for (const f of ENCRYPTED_META_FIELDS) { + const plain = obj[f] || ''; + if (plain) { + const c = await encryptPwd(plain); + obj[f + '_enc'] = c.encrypted; + obj[f + '_iv'] = c.iv; + } else { + obj[f + '_enc'] = ''; + obj[f + '_iv'] = ''; + } + obj[f] = ''; } - obj.username = ''; return obj; } -// One-time sweep: re-save (full re-ship PUT) every entry that still carries a -// cleartext username with no ciphertext yet, so the cleartext is wiped from -// the DB. Runs at enterApp; no-op once every row is migrated. Best-effort — -// individual failures are skipped and retried on the next unlock. The PUT -// bumps updated_at (accepted one-time sync churn; the plaintext is unchanged -// so other devices converge to the same value). -async function migrateUsernamesAtRest() { +// True when a row still holds cleartext in a metadata field that hasn't been +// encrypted yet (cleartext present but no matching _enc). +function entryNeedsMetaMigration(e) { + if (!e) return false; + return ENCRYPTED_META_FIELDS.some(f => (e[f] || '') !== '' && !e[f + '_enc']); +} + +// One-time sweep: re-save (full re-ship PUT) every entry that still carries +// cleartext metadata (username/site/title/tags) with no ciphertext yet, so the +// cleartext is wiped from the DB. Runs at enterApp; no-op once every row is +// migrated. Best-effort — individual failures are skipped and retried on the +// next unlock. The PUT bumps updated_at (accepted one-time sync churn; the +// plaintext is unchanged so other devices converge to the same value). +async function migrateMetadataAtRest() { if (!state.cryptoKey) return; let pool = (state.entries || []).slice(); // Trashed rows live in state.trashed (loaded on demand), not state.entries, - // so the live-only sweep would leave a soft-deleted entry's username in + // so the live-only sweep would leave a soft-deleted entry's metadata in // cleartext until purge. Fetch + decrypt the trash so it's covered too — // the PUT updates the row's fields without touching `deleted`, so it stays // in the trash. Trashed rows aren't in the sync snapshot, so no churn. try { const trash = await api('/entries?deleted=1', { headers: authHeaders() }); if (Array.isArray(trash)) { - await decryptEntryUsernames(trash); + await decryptEntryMeta(trash); pool = pool.concat(trash); } } catch (_) {} - const todo = pool.filter(e => e && !e.username_enc && (e.username || '') !== ''); + const todo = pool.filter(entryNeedsMetaMigration); if (todo.length === 0) return; let migrated = 0; for (const e of todo) { @@ -1530,7 +1551,7 @@ async function migrateUsernamesAtRest() { await api('/entries/' + e.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ site: e.site, title: e.title || '', username: e.username, @@ -1556,7 +1577,7 @@ async function loadEntries() { try { const r = await api('/entries', { headers: authHeaders() }); state.entries = Array.isArray(r) ? r : []; - await decryptEntryUsernames(state.entries); + await decryptEntryMeta(state.entries); } catch (e) { if (e.message === 'Invalid session' || e.message === 'Session expired') { return doLogout(); @@ -1569,7 +1590,7 @@ async function loadTrash() { try { const r = await api('/entries?deleted=1', { headers: authHeaders() }); state.trashed = Array.isArray(r) ? r : []; - await decryptEntryUsernames(state.trashed); + await decryptEntryMeta(state.trashed); state.trashedCount = state.trashed.length; } catch (e) { state.trashed = []; } } @@ -2234,7 +2255,7 @@ async function addTagToEntry(id, tag) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ site: e.site, title: e.title || '', username: e.username, @@ -3509,7 +3530,7 @@ async function batchMoveToFolder(folder) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ // Re-ship the full payload — partial PUT would wipe // TOTP / custom_fields / kind / template / username_enc // (see also moveEntryToFolder and the "places à toucher" @@ -3550,7 +3571,7 @@ async function batchAddTag(tag) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ site: e.site, title: e.title || '', username: e.username, @@ -4751,7 +4772,7 @@ async function soSave() { cfIv = e.iv; } - const body = JSON.stringify(await withEncryptedUsername({ + const body = JSON.stringify(await withEncryptedMeta({ site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, @@ -5079,7 +5100,7 @@ async function saveEntry(e) { if (!site || !pwd) return toast('Site and password required', 'error'); const enc = await encryptPwd(pwd); - const body = JSON.stringify(await withEncryptedUsername({ + const body = JSON.stringify(await withEncryptedMeta({ site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv, folder: fold, tags, })); @@ -5175,7 +5196,7 @@ async function duplicateEntry(entry) { const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ site: entry.site || '', title: entryDisplayName(entry) + ' (copy)', username: entry.username || '', @@ -5339,7 +5360,7 @@ async function moveEntryToFolder(id, folder) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(await withEncryptedUsername({ + body: JSON.stringify(await withEncryptedMeta({ site: e.site, title: e.title || '', username: e.username, @@ -7182,13 +7203,19 @@ async function doChangeMasterPassword() { cfIv = c.iv; } } - // Username is encrypted at rest too — re-encrypt the plaintext - // (e.username was decrypted at load) under the NEW key. - let uEnc = '', uIv = ''; - if (e.username) { - state.cryptoKey = newKey; - const u = await encryptPwd(e.username); - uEnc = u.encrypted; uIv = u.iv; + // Encrypted metadata (username/site/title/tags) — re-encrypt + // each plaintext (decrypted at load) under the NEW key. + state.cryptoKey = newKey; + const meta = {}; + for (const f of ENCRYPTED_META_FIELDS) { + if (e[f]) { + const c = await encryptPwd(e[f]); + meta[f + '_enc'] = c.encrypted; + meta[f + '_iv'] = c.iv; + } else { + meta[f + '_enc'] = ''; + meta[f + '_iv'] = ''; + } } encrypted.push({ id: e.id, @@ -7198,8 +7225,7 @@ async function doChangeMasterPassword() { totp_iv: totpIv, custom_fields: cfEnc, custom_fields_iv: cfIv, - username_enc: uEnc, - username_iv: uIv, + ...meta, }); } finally { state.cryptoKey = oldKey; // restore until server confirms @@ -7976,7 +8002,7 @@ async function enterApp() { // One-time metadata-at-rest migration: encrypt the cleartext username of // any row that predates the encrypted column. Fire-and-forget so it never // blocks the UI; each pass shrinks the backlog until nothing's left. - migrateUsernamesAtRest(); + migrateMetadataAtRest(); // Fire-and-forget HIBP scan if the user opted in. Runs in background, // re-renders when done to show badges. if (state.hibpEnabled) hibpCheckAllEntries(); diff --git a/js/tests/merge.test.js b/js/tests/merge.test.js index 17a6514..a5b37c6 100644 --- a/js/tests/merge.test.js +++ b/js/tests/merge.test.js @@ -100,6 +100,15 @@ const remoteEntry = (o) => Object.assign({ created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, o); +// Metadata (site/title/username/tags) is encrypted at rest on the import path, +// so a stored row carries _enc + a blank cleartext . Decrypt to check +// the value; seeded rows (db.seedEntry) keep cleartext, so fall back to it. +async function decField(T, row, field) { + const enc = row[field + '_enc'], iv = row[field + '_iv']; + if (enc && iv) return await T.decryptPwd(enc, iv); + return row[field]; +} + test('merge: remote-only entry is added locally, keeping its uuid', async () => { const { T, db } = await freshMerge(); const res = await T.applyRemoteSnapshot({ @@ -116,7 +125,10 @@ test('merge: remote-only entry is added locally, keeping its uuid', async () => assert.ok(db.entries[0].username_enc, 'username_enc must be present'); assert.ok(db.entries[0].username_iv, 'username_iv must be present'); assert.notEqual(db.entries[0].username_enc, 'u', 'must not store plaintext'); - assert.equal(db.entries[0].site, 'https://new.example'); + // site is encrypted too: blank cleartext + ciphertext that decrypts back. + assert.equal(db.entries[0].site, '', 'cleartext site must be blanked'); + assert.ok(db.entries[0].site_enc, 'site_enc must be present'); + assert.equal(await decField(T, db.entries[0], 'site'), 'https://new.example'); }); test('merge: remote entry newer than local → PUT updates it', async () => { @@ -128,7 +140,7 @@ test('merge: remote entry newer than local → PUT updates it', async () => { }); assert.equal(res.updated, 1); assert.equal(res.added, 0); - assert.equal(db.entries[0].site, 'https://newer'); + assert.equal(await decField(T, db.entries[0], 'site'), 'https://newer'); }); test('merge: remote entry OLDER than local → skipped (last-write-wins keeps local)', async () => {