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
+23 -17
View File
@@ -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** : Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** :
`encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`, `encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`,
**`username_enc/username_iv`** (métadonnée-at-rest, cf. plus bas), **`username_enc/iv`, `site_enc/iv`, `title_enc/iv`, `tags_enc/iv`**
plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title, (métadonnées-at-rest, cf. plus bas), plus le champ-icône `icon_b64` et les
folder, tags, kind, template`). `template` est le sous-type méta non chiffrées (`folder, kind, template`). `template` est le sous-type
(ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le (ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le
label de card/table — vide pour login/note génériques. label de card/table — vide pour login/note génériques.
**`username` chiffré (§1.3)** : la colonne `username` en clair est en voie **Métadonnées chiffrées (§1.3)** : `username`, `site`, `title`, `tags` sont
d'extinction — les nouvelles écritures y mettent `''` et rangent le chiffré chiffrés au repos (colonnes `<f>_enc/<f>_iv`, AES-GCM sous la clé du vault
dans `username_enc/username_iv` (AES-GCM sous la clé du vault, comme comme `encrypted_password`). Les colonnes en clair reçoivent `''` sur écriture.
`encrypted_password`). `loadEntries`/`loadTrash` déchiffrent → `e.username` `loadEntries`/`loadTrash` déchiffrent → `e.<f>` en mémoire, donc
en mémoire, donc **recherche/tri/render/autofill-match marchent inchangés** **recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est
(tout est déjà côté client). Choke-point d'écriture : `withEncryptedUsername(obj)` déjà côté client). Liste des champs : `ENCRYPTED_META_FIELDS =
(chiffre `obj.username`, blanchit le clair) — enveloppe **chaque** body ['username','site','title','tags']`. Choke-point d'écriture :
POST/PUT `/entries` (saveEntry, soSave, duplicateEntry, moveEntryToFolder, `withEncryptedMeta(obj)` (chiffre chaque champ, blanchit le clair) — enveloppe
addTagToEntry, batchMove/AddTag, encryptImportEntry, migration). Anciennes **chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry,
lignes migrées au unlock par `migrateUsernamesAtRest` (PUT re-ship, bump moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry,
`updated_at` assumé une fois). Rotation master-pw re-chiffre `username_enc` migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au
sous la nouvelle clé (JS loop + UPDATE serveur). Le `?q=` serveur ne LIKE unlock par `migrateMetadataAtRest` (PUT re-ship, y compris corbeille, bump
plus que `site`. `site`/`title`/`tags` restent en clair (à chiffrer plus `updated_at` assumé une fois). Rotation master-pw re-chiffre les 4 champs sous
tard, même patron — cf. [[encrypt-metadata-plan]]). 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** 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 mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
+12 -11
View File
@@ -85,20 +85,21 @@ Documenté mais à rappeler pour un futur modèle de menace :
- `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés** - `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés**
- `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé) - `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé)
- `vault_entries` : ~~`username`~~ **chiffré (2026-07-08)** ; `site`, `title`, - `vault_entries` : ~~`username`, `site`, `title`, `tags`~~ **chiffrés
`folder`, `tags`, `kind`, `template` encore en clair. (2026-07-09)** ; `folder`, `kind`, `template` encore en clair.
**`username` chiffré au repos (✅ 2026-07-08)** : colonnes **`username` + `site` + `title` + `tags` chiffrés au repos (✅ 2026-07-09)** :
`username_enc/username_iv` (AES-GCM sous la clé du vault). Clé de l'approche : colonnes `<f>_enc/<f>_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, 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 donc AES-GCM plein (IV aléatoire), pas de searchable-encryption. Choke-point
`withEncryptedUsername` sur tous les writes ; migration `migrateUsernamesAtRest` `withEncryptedMeta` sur tous les writes ; `decryptEntryMeta` au load ; migration
au unlock pour les vieilles lignes ; rotation re-chiffre. Détails dans `migrateMetadataAtRest` (live + corbeille) ; rotation re-chiffre les 4.
CLAUDE.md « Entry payload ». **Reste** : `site`/`title`/`tags` (même patron, Validation serveur « Site required » retirée + `?q=` neutralisé (LIKE inutile
[[encrypt-metadata-plan]]). Résiduel : nombre de lignes, timestamps, métadonnées sur ciphertext). Détails CLAUDE.md « Entry payload ». **`username` validé
d'attachments. **✅ Validé runtime (2026-07-09)** : après rebuild + unlock, la runtime le 2026-07-09** (0 en clair après migration). **`site`/`title`/`tags`
base montre 0 username en clair (54 entries, 43 `username_enc`, migration NON encore compilés/testés runtime** — même patron, gros changement
`migrateUsernamesAtRest` complétée) et l'affichage/recherche marchent. 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 ### 1.4 🟡 Snapshot de sync = tout le vault en clair sous le sync password
@@ -1041,6 +1041,9 @@ begin
' totp_secret = :ts, totp_iv = :tiv, ' + ' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' + ' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' username_enc = :uenc, username_iv = :uiv, ' + ' 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 ' + ' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid'; 'WHERE id = :id AND user_id = :uid';
@@ -1056,6 +1059,12 @@ begin
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); var LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
var LUEnc := LEntry.GetValue<string>('username_enc', ''); var LUEnc := LEntry.GetValue<string>('username_enc', '');
var LUIv := LEntry.GetValue<string>('username_iv', ''); 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 if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
@@ -1079,10 +1088,28 @@ begin
else LQ.ParamByName('cfiv').Value := LCfIv; else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('uenc').DataType := ftMemo; LQ.ParamByName('uenc').DataType := ftMemo;
LQ.ParamByName('uiv').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 if LUEnc = '' then LQ.ParamByName('uenc').Clear
else LQ.ParamByName('uenc').Value := LUEnc; else LQ.ParamByName('uenc').Value := LUEnc;
if LUIv = '' then LQ.ParamByName('uiv').Clear if LUIv = '' then LQ.ParamByName('uiv').Clear
else LQ.ParamByName('uiv').Value := LUIv; 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; LQ.ExecSQL;
end; end;
// Password history is encrypted with the OLD vault key — we // Password history is encrypted with the OLD vault key — we
+106 -47
View File
@@ -55,6 +55,26 @@ begin
Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime); Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime);
end; 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 ========================================================== // ===== GET /entries ==========================================================
procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo; procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo;
@@ -83,24 +103,14 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
if LSearch <> '' then // The ?search= query param is now ignored server-side: site AND username
begin // are both encrypted at rest, so a SQL LIKE can't match them. The
LQ.SQL.Text := // frontend loads the whole (decrypted) vault and filters client-side —
'SELECT * FROM vault_entries ' + // it never sends ?search=. Kept LSearch read for API back-compat only.
'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 := LQ.SQL.Text :=
'SELECT * FROM vault_entries ' + 'SELECT * FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = :del ' + 'WHERE user_id = :uid AND deleted = :del ' +
'ORDER BY updated_at DESC'; 'ORDER BY updated_at DESC';
end;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('del').AsInteger := LDeleted; LQ.ParamByName('del').AsInteger := LDeleted;
LQ.Open; LQ.Open;
@@ -124,6 +134,14 @@ begin
LObj.AddPair('username_iv', TJSONNull.Create) LObj.AddPair('username_iv', TJSONNull.Create)
else else
LObj.AddPair('username_iv', LQ.FieldByName('username_iv').AsString); 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('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
LObj.AddPair('iv', LQ.FieldByName('iv').AsString); LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString); LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString);
@@ -316,7 +334,8 @@ var
LUserId, LNewId: Integer; LUserId, LNewId: Integer;
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, 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; LQ: TFDQuery;
begin begin
try try
@@ -336,6 +355,14 @@ begin
// in username_enc/username_iv instead. // in username_enc/username_iv instead.
LUserEnc := LBody.GetValue<string>('username_enc', ''); LUserEnc := LBody.GetValue<string>('username_enc', '');
LUserIv := LBody.GetValue<string>('username_iv', ''); LUserIv := LBody.GetValue<string>('username_iv', '');
// Encrypted site / title / tags — same scheme. Cleartext siblings are ''
// when these are present.
LSiteEnc := LBody.GetValue<string>('site_enc', '');
LSiteIv := LBody.GetValue<string>('site_iv', '');
LTitleEnc:= LBody.GetValue<string>('title_enc', '');
LTitleIv := LBody.GetValue<string>('title_iv', '');
LTagsEnc := LBody.GetValue<string>('tags_enc', '');
LTagsIv := LBody.GetValue<string>('tags_iv', '');
LFolder := Trim(LBody.GetValue<string>('folder', 'All')); LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
LEnc := LBody.GetValue<string>('encrypted_password', ''); LEnc := LBody.GetValue<string>('encrypted_password', '');
LIV := LBody.GetValue<string>('iv', ''); LIV := LBody.GetValue<string>('iv', '');
@@ -358,17 +385,15 @@ begin
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login'; if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
// 'login' entries require a site; 'note' only needs encrypted body.
if LEnc = '' then if LEnc = '' then
begin begin
TJSONHelper.SendError(AResponse, 400, 'Content required'); TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit; Exit;
end; end;
if (LKind = 'login') and (LSite = '') then // NOTE: the old "Site required" check is gone — site is now encrypted at
begin // rest (LSite is '' when the client sent site_enc), so the server can't
TJSONHelper.SendError(AResponse, 400, 'Site required'); // read it. The client already enforces "site + password required" before
Exit; // saving a login.
end;
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2) LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
@@ -380,21 +405,27 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, username_enc, username_iv, ' + '(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, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' + ' 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)'; ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
// Encrypted username: NULL when not supplied (pre-migration client or a // Encrypted metadata: NULL when not supplied (pre-migration client or a
// row that genuinely has no username) so GET emits JSON null. // row with no value) so GET emits JSON null and the client falls back.
LQ.ParamByName('uenc').DataType := ftMemo; BindNullable(LQ, 'uenc', LUserEnc);
LQ.ParamByName('uiv').DataType := ftMemo; BindNullable(LQ, 'uiv', LUserIv);
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; BindNullable(LQ, 'senc', LSiteEnc);
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; 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('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
@@ -467,7 +498,8 @@ var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, 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; LHasTemplate: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
@@ -492,6 +524,12 @@ begin
LUser := Trim(LBody.GetValue<string>('username', '')); LUser := Trim(LBody.GetValue<string>('username', ''));
LUserEnc := LBody.GetValue<string>('username_enc', ''); LUserEnc := LBody.GetValue<string>('username_enc', '');
LUserIv := LBody.GetValue<string>('username_iv', ''); LUserIv := LBody.GetValue<string>('username_iv', '');
LSiteEnc := LBody.GetValue<string>('site_enc', '');
LSiteIv := LBody.GetValue<string>('site_iv', '');
LTitleEnc:= LBody.GetValue<string>('title_enc', '');
LTitleIv := LBody.GetValue<string>('title_iv', '');
LTagsEnc := LBody.GetValue<string>('tags_enc', '');
LTagsIv := LBody.GetValue<string>('tags_iv', '');
LFolder := Trim(LBody.GetValue<string>('folder', 'All')); LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
LEnc := LBody.GetValue<string>('encrypted_password', ''); LEnc := LBody.GetValue<string>('encrypted_password', '');
LIV := LBody.GetValue<string>('iv', ''); LIV := LBody.GetValue<string>('iv', '');
@@ -516,11 +554,8 @@ begin
TJSONHelper.SendError(AResponse, 400, 'Content required'); TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit; Exit;
end; end;
if (LKind = 'login') and (LSite = '') then // "Site required" removed — site is encrypted at rest (LSite is '' when the
begin // client sent site_enc). The client enforces it before saving.
TJSONHelper.SendError(AResponse, 400, 'Site required');
Exit;
end;
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2) LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
DB.Lock; DB.Lock;
@@ -562,6 +597,8 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET site=:s, title=:tt, username=:u, username_enc=:uenc, username_iv=:uiv, ' + '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, ' + ' encrypted_password=:e, iv=:i, ' +
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
' custom_fields=:cf, custom_fields_iv=:cfiv, ' + ' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
@@ -573,10 +610,14 @@ begin
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('uenc').DataType := ftMemo; BindNullable(LQ, 'uenc', LUserEnc);
LQ.ParamByName('uiv').DataType := ftMemo; BindNullable(LQ, 'uiv', LUserIv);
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; BindNullable(LQ, 'senc', LSiteEnc);
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; 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('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
@@ -1148,7 +1189,8 @@ var
LBody, LObj, LEntry: TJSONObject; LBody, LObj, LEntry: TJSONObject;
LArr, LIds: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, 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; LQ, LTomb: TFDQuery;
begin begin
try try
@@ -1202,10 +1244,12 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, username_enc, username_iv, ' + '(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, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, uuid, created_at, updated_at) ' + ' 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)'; ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)';
// Declare optional param types ONCE — the prepared statement is // Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the // reused across every imported entry, and FireDAC needs the
@@ -1223,6 +1267,12 @@ begin
LQ.ParamByName('tpl').DataType := ftString; LQ.ParamByName('tpl').DataType := ftString;
LQ.ParamByName('uenc').DataType := ftMemo; LQ.ParamByName('uenc').DataType := ftMemo;
LQ.ParamByName('uiv').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 for I := 0 to LArr.Count - 1 do
begin begin
@@ -1232,6 +1282,12 @@ begin
LUser := Trim(LEntry.GetValue<string>('username', '')); LUser := Trim(LEntry.GetValue<string>('username', ''));
LUserEnc := LEntry.GetValue<string>('username_enc', ''); LUserEnc := LEntry.GetValue<string>('username_enc', '');
LUserIv := LEntry.GetValue<string>('username_iv', ''); LUserIv := LEntry.GetValue<string>('username_iv', '');
LSiteEnc := LEntry.GetValue<string>('site_enc', '');
LSiteIv := LEntry.GetValue<string>('site_iv', '');
LTitleEnc:= LEntry.GetValue<string>('title_enc', '');
LTitleIv := LEntry.GetValue<string>('title_iv', '');
LTagsEnc := LEntry.GetValue<string>('tags_enc', '');
LTagsIv := LEntry.GetValue<string>('tags_iv', '');
LFolder := Trim(LEntry.GetValue<string>('folder', 'All')); LFolder := Trim(LEntry.GetValue<string>('folder', 'All'));
LEnc := LEntry.GetValue<string>('encrypted_password', ''); LEnc := LEntry.GetValue<string>('encrypted_password', '');
LIV := LEntry.GetValue<string>('iv', ''); LIV := LEntry.GetValue<string>('iv', '');
@@ -1256,18 +1312,21 @@ begin
LIds.AddElement(TJSONNumber.Create(-1)); LIds.AddElement(TJSONNumber.Create(-1));
Continue; Continue;
end; end;
if (LKind = 'login') and (LSite = '') then // No "site required" skip — site is encrypted (LSite = '' when the
begin // row carries site_enc); the client validated before import.
LIds.AddElement(TJSONNumber.Create(-1));
Continue;
end;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc; BindNullable(LQ, 'uenc', LUserEnc);
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv; 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('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
+11
View File
@@ -361,6 +361,17 @@ begin
// value, so no server-side change to those. NULL = not yet encrypted. // value, so no server-side change to those. NULL = not yet encrypted.
AddColumnIfMissing('vault_entries', 'username_enc', 'TEXT'); AddColumnIfMissing('vault_entries', 'username_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'username_iv', '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,..."). // 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. // 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. // NULL = no icon cached → JS falls back to the first-letter avatar.
+1 -1
View File
@@ -456,7 +456,7 @@ async function encryptImportEntry(plain) {
: (plain.tags || ''); : (plain.tags || '');
// Encrypt the username at rest (username_enc/username_iv, cleartext blanked) // 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. // via the shared choke point in app.js — same as the interactive save path.
return await withEncryptedUsername({ return await withEncryptedMeta({
uuid: plain.uuid || '', uuid: plain.uuid || '',
site: plain.site, site: plain.site,
title: plain.title || '', title: plain.title || '',
+76 -50
View File
@@ -1468,61 +1468,82 @@ function folderMetaFor(name) {
return { color: (f && f.color) || '', icon: (f && f.icon) || '' }; return { color: (f && f.color) || '', icon: (f && f.icon) || '' };
} }
// Metadata-at-rest: username is stored encrypted (username_enc/username_iv). // Metadata-at-rest: username/site/title/tags are stored encrypted
// Decrypt it into e.username in place so all downstream code (render, search, // (<f>_enc/<f>_iv). Decrypt each into e.<f> in place so all downstream code
// autofill-match, sort) works on the plaintext transparently — exactly as // (render, search, autofill-match, sort, favicon) works on the plaintext
// when username was a cleartext column. Rows not yet migrated have no // transparently — exactly as when they were cleartext columns. Rows not yet
// username_enc → their cleartext e.username is kept as-is (fallback). // migrated have no <f>_enc → their cleartext e.<f> is kept (fallback).
async function decryptEntryUsernames(list) { // (ENCRYPTED_META_FIELDS is declared just below, resolved at call time.)
async function decryptEntryMeta(list) {
for (const e of (list || [])) { for (const e of (list || [])) {
if (e && e.username_enc && e.username_iv) { if (!e) continue;
const u = await decryptPwd(e.username_enc, e.username_iv); for (const f of ENCRYPTED_META_FIELDS) {
if (u !== '[ERROR]') e.username = u; 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` // Fields encrypted at rest as metadata (§1.3). Each `f` has cleartext `f`
// holds PLAINTEXT, encrypt it into username_enc/username_iv, and blank the // (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run
// cleartext field so nothing readable is persisted. Wrap every POST/PUT // client-side on the decrypted in-memory value, so encrypting these is
// /entries body in this. Empty username → all cleared (server stores NULL // transparent. `folder` stays cleartext (server folder-reassign query).
// ciphertext + '' username). Mutates + returns the object for convenience. const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags'];
async function withEncryptedUsername(obj) {
const plain = (obj && obj.username) || ''; // Choke point for the write path: take an entry body object whose metadata
// fields hold PLAINTEXT, encrypt each into <f>_enc/<f>_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) { if (plain) {
const c = await encryptPwd(plain); const c = await encryptPwd(plain);
obj.username_enc = c.encrypted; obj[f + '_enc'] = c.encrypted;
obj.username_iv = c.iv; obj[f + '_iv'] = c.iv;
} else { } else {
obj.username_enc = ''; obj[f + '_enc'] = '';
obj.username_iv = ''; obj[f + '_iv'] = '';
}
obj[f] = '';
} }
obj.username = '';
return obj; return obj;
} }
// One-time sweep: re-save (full re-ship PUT) every entry that still carries a // True when a row still holds cleartext in a metadata field that hasn't been
// cleartext username with no ciphertext yet, so the cleartext is wiped from // encrypted yet (cleartext present but no matching <f>_enc).
// the DB. Runs at enterApp; no-op once every row is migrated. Best-effort — function entryNeedsMetaMigration(e) {
// individual failures are skipped and retried on the next unlock. The PUT if (!e) return false;
// bumps updated_at (accepted one-time sync churn; the plaintext is unchanged return ENCRYPTED_META_FIELDS.some(f => (e[f] || '') !== '' && !e[f + '_enc']);
// so other devices converge to the same value). }
async function migrateUsernamesAtRest() {
// 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; if (!state.cryptoKey) return;
let pool = (state.entries || []).slice(); let pool = (state.entries || []).slice();
// Trashed rows live in state.trashed (loaded on demand), not state.entries, // 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 — // 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 // 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. // in the trash. Trashed rows aren't in the sync snapshot, so no churn.
try { try {
const trash = await api('/entries?deleted=1', { headers: authHeaders() }); const trash = await api('/entries?deleted=1', { headers: authHeaders() });
if (Array.isArray(trash)) { if (Array.isArray(trash)) {
await decryptEntryUsernames(trash); await decryptEntryMeta(trash);
pool = pool.concat(trash); pool = pool.concat(trash);
} }
} catch (_) {} } catch (_) {}
const todo = pool.filter(e => e && !e.username_enc && (e.username || '') !== ''); const todo = pool.filter(entryNeedsMetaMigration);
if (todo.length === 0) return; if (todo.length === 0) return;
let migrated = 0; let migrated = 0;
for (const e of todo) { for (const e of todo) {
@@ -1530,7 +1551,7 @@ async function migrateUsernamesAtRest() {
await api('/entries/' + e.id, { await api('/entries/' + e.id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({ body: JSON.stringify(await withEncryptedMeta({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -1556,7 +1577,7 @@ async function loadEntries() {
try { try {
const r = await api('/entries', { headers: authHeaders() }); const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : []; state.entries = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.entries); await decryptEntryMeta(state.entries);
} catch (e) { } catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') { if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout(); return doLogout();
@@ -1569,7 +1590,7 @@ async function loadTrash() {
try { try {
const r = await api('/entries?deleted=1', { headers: authHeaders() }); const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : []; state.trashed = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.trashed); await decryptEntryMeta(state.trashed);
state.trashedCount = state.trashed.length; state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; } } catch (e) { state.trashed = []; }
} }
@@ -2234,7 +2255,7 @@ async function addTagToEntry(id, tag) {
await api('/entries/' + id, { await api('/entries/' + id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({ body: JSON.stringify(await withEncryptedMeta({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -3509,7 +3530,7 @@ async function batchMoveToFolder(folder) {
await api('/entries/' + id, { await api('/entries/' + id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), 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 // Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template / username_enc // TOTP / custom_fields / kind / template / username_enc
// (see also moveEntryToFolder and the "places à toucher" // (see also moveEntryToFolder and the "places à toucher"
@@ -3550,7 +3571,7 @@ async function batchAddTag(tag) {
await api('/entries/' + id, { await api('/entries/' + id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({ body: JSON.stringify(await withEncryptedMeta({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -4751,7 +4772,7 @@ async function soSave() {
cfIv = e.iv; cfIv = e.iv;
} }
const body = JSON.stringify(await withEncryptedUsername({ const body = JSON.stringify(await withEncryptedMeta({
site, title: title.trim(), username: user, site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv, encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv, totp_secret: totpEnc, totp_iv: totpIv,
@@ -5079,7 +5100,7 @@ async function saveEntry(e) {
if (!site || !pwd) return toast('Site and password required', 'error'); if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd); 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, site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags, folder: fold, tags,
})); }));
@@ -5175,7 +5196,7 @@ async function duplicateEntry(entry) {
const r = await api('/entries', { const r = await api('/entries', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({ body: JSON.stringify(await withEncryptedMeta({
site: entry.site || '', site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)', title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '', username: entry.username || '',
@@ -5339,7 +5360,7 @@ async function moveEntryToFolder(id, folder) {
await api('/entries/' + id, { await api('/entries/' + id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({ body: JSON.stringify(await withEncryptedMeta({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -7182,13 +7203,19 @@ async function doChangeMasterPassword() {
cfIv = c.iv; cfIv = c.iv;
} }
} }
// Username is encrypted at rest too — re-encrypt the plaintext // Encrypted metadata (username/site/title/tags) — re-encrypt
// (e.username was decrypted at load) under the NEW key. // each plaintext (decrypted at load) under the NEW key.
let uEnc = '', uIv = '';
if (e.username) {
state.cryptoKey = newKey; state.cryptoKey = newKey;
const u = await encryptPwd(e.username); const meta = {};
uEnc = u.encrypted; uIv = u.iv; 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({ encrypted.push({
id: e.id, id: e.id,
@@ -7198,8 +7225,7 @@ async function doChangeMasterPassword() {
totp_iv: totpIv, totp_iv: totpIv,
custom_fields: cfEnc, custom_fields: cfEnc,
custom_fields_iv: cfIv, custom_fields_iv: cfIv,
username_enc: uEnc, ...meta,
username_iv: uIv,
}); });
} finally { } finally {
state.cryptoKey = oldKey; // restore until server confirms 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 // One-time metadata-at-rest migration: encrypt the cleartext username of
// any row that predates the encrypted column. Fire-and-forget so it never // 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. // 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, // Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges. // re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries(); if (state.hibpEnabled) hibpCheckAllEntries();
+14 -2
View File
@@ -100,6 +100,15 @@ const remoteEntry = (o) => Object.assign({
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}, o); }, o);
// Metadata (site/title/username/tags) is encrypted at rest on the import path,
// so a stored row carries <f>_enc + a blank cleartext <f>. 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 () => { test('merge: remote-only entry is added locally, keeping its uuid', async () => {
const { T, db } = await freshMerge(); const { T, db } = await freshMerge();
const res = await T.applyRemoteSnapshot({ 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_enc, 'username_enc must be present');
assert.ok(db.entries[0].username_iv, 'username_iv 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.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 () => { 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.updated, 1);
assert.equal(res.added, 0); 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 () => { test('merge: remote entry OLDER than local → skipped (last-write-wins keeps local)', async () => {