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
+17 -1
View File
@@ -429,11 +429,27 @@ 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),
plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title, plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title,
username, folder, tags, kind, template`). `template` est le sous-type folder, tags, 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
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]]).
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
sur certaines actions : sur certaines actions :
+12 -5
View File
@@ -85,12 +85,19 @@ 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` : `site`, `title`, `username`, `folder`, `tags`, `kind`, - `vault_entries` : ~~`username`~~ **chiffré (2026-07-08)** ; `site`, `title`,
`template` **non chiffrés** (nécessaire pour recherche/tri sans déchiffrer) `folder`, `tags`, `kind`, `template` encore en clair.
Un attaquant avec accès disque voit la liste des sites et usernames. Pour **`username` chiffré au repos (✅ 2026-07-08)** : colonnes
un vault perso c'est un compromis acceptable (recherche instantanée), mais `username_enc/username_iv` (AES-GCM sous la clé du vault). Clé de l'approche :
à documenter clairement pour l'utilisateur. 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. **Non compilé/testé runtime Delphi cette session**
gros changement multi-handlers, rebuild + test soigneux requis.
### 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
@@ -1040,6 +1040,7 @@ begin
' encrypted_password = :ep, iv = :iv, ' + ' encrypted_password = :ep, iv = :iv, ' +
' 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, ' +
' updated_at = CURRENT_TIMESTAMP ' + ' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid'; 'WHERE id = :id AND user_id = :uid';
@@ -1053,6 +1054,8 @@ begin
LTotpIv := LEntry.GetValue<string>('totp_iv', ''); LTotpIv := LEntry.GetValue<string>('totp_iv', '');
var LCf := LEntry.GetValue<string>('custom_fields', ''); var LCf := LEntry.GetValue<string>('custom_fields', '');
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); 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 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]);
@@ -1074,6 +1077,12 @@ begin
else LQ.ParamByName('cf').Value := LCf; else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear if LCfIv = '' then LQ.ParamByName('cfiv').Clear
else LQ.ParamByName('cfiv').Value := LCfIv; 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; LQ.ExecSQL;
end; end;
// Password history is encrypted with the OLD vault key — we // Password history is encrypted with the OLD vault key — we
+53 -12
View File
@@ -88,7 +88,9 @@ 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 ' +
'AND (site LIKE :q OR username LIKE :q) ' + // 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'; 'ORDER BY updated_at DESC';
LQ.ParamByName('q').AsString := '%' + LSearch + '%'; LQ.ParamByName('q').AsString := '%' + LSearch + '%';
end end
@@ -108,7 +110,20 @@ begin
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('site', LQ.FieldByName('site').AsString); LObj.AddPair('site', LQ.FieldByName('site').AsString);
LObj.AddPair('title', LQ.FieldByName('title').AsString); LObj.AddPair('title', LQ.FieldByName('title').AsString);
// Cleartext username: '' for migrated rows (ciphertext lives in
// username_enc). Old rows still carry it until the client sweep.
LObj.AddPair('username', LQ.FieldByName('username').AsString); LObj.AddPair('username', LQ.FieldByName('username').AsString);
// Encrypted username (AES-GCM under the vault key). NULL → JSON null
// so the client knows the row isn't migrated yet and falls back to
// the cleartext `username` above.
if LQ.FieldByName('username_enc').IsNull then
LObj.AddPair('username_enc', TJSONNull.Create)
else
LObj.AddPair('username_enc', LQ.FieldByName('username_enc').AsString);
if LQ.FieldByName('username_iv').IsNull then
LObj.AddPair('username_iv', TJSONNull.Create)
else
LObj.AddPair('username_iv', LQ.FieldByName('username_iv').AsString);
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);
@@ -300,8 +315,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LNewId: Integer; LUserId, LNewId: Integer;
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -316,6 +331,11 @@ begin
LSite := Trim(LBody.GetValue<string>('site', '')); LSite := Trim(LBody.GetValue<string>('site', ''));
LTitle := Trim(LBody.GetValue<string>('title', '')); LTitle := Trim(LBody.GetValue<string>('title', ''));
LUser := Trim(LBody.GetValue<string>('username', '')); LUser := Trim(LBody.GetValue<string>('username', ''));
// Encrypted username (metadata-at-rest). When present the client has
// already wiped the cleartext `username` to '' — the ciphertext is stored
// in username_enc/username_iv instead.
LUserEnc := LBody.GetValue<string>('username_enc', '');
LUserIv := LBody.GetValue<string>('username_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', '');
@@ -359,15 +379,22 @@ begin
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, title, username, username_enc, username_iv, ' +
' 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, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :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
// 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;
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;
@@ -439,8 +466,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LKind, LCf, LCfIv, LTemplate: string; LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate: string;
LHasTemplate: Boolean; LHasTemplate: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
@@ -463,6 +490,8 @@ begin
LSite := Trim(LBody.GetValue<string>('site', '')); LSite := Trim(LBody.GetValue<string>('site', ''));
LTitle := Trim(LBody.GetValue<string>('title', '')); LTitle := Trim(LBody.GetValue<string>('title', ''));
LUser := Trim(LBody.GetValue<string>('username', '')); LUser := Trim(LBody.GetValue<string>('username', ''));
LUserEnc := LBody.GetValue<string>('username_enc', '');
LUserIv := LBody.GetValue<string>('username_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', '');
@@ -532,7 +561,8 @@ begin
if LHasTemplate then LTemplateSet := ', template=:tpl'; if LHasTemplate then LTemplateSet := ', template=:tpl';
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + 'SET site=:s, title=:tt, username=:u, username_enc=:uenc, username_iv=:uiv, ' +
' 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, ' +
' updated_at=:c, ' + ' updated_at=:c, ' +
@@ -543,6 +573,10 @@ 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;
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;
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;
@@ -1113,8 +1147,8 @@ var
LUserId, I, LImported, LNewId: Integer; LUserId, I, LImported, LNewId: Integer;
LBody, LObj, LEntry: TJSONObject; LBody, LObj, LEntry: TJSONObject;
LArr, LIds: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ, LTomb: TFDQuery; LQ, LTomb: TFDQuery;
begin begin
try try
@@ -1167,10 +1201,11 @@ begin
'WHERE user_id = :uid AND uuid = :uuid'; 'WHERE user_id = :uid AND uuid = :uuid';
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, title, username, username_enc, username_iv, ' +
' 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, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + 'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :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
@@ -1186,6 +1221,8 @@ begin
LQ.ParamByName('cfiv').DataType := ftMemo; LQ.ParamByName('cfiv').DataType := ftMemo;
LQ.ParamByName('ic').DataType := ftMemo; LQ.ParamByName('ic').DataType := ftMemo;
LQ.ParamByName('tpl').DataType := ftString; LQ.ParamByName('tpl').DataType := ftString;
LQ.ParamByName('uenc').DataType := ftMemo;
LQ.ParamByName('uiv').DataType := ftMemo;
for I := 0 to LArr.Count - 1 do for I := 0 to LArr.Count - 1 do
begin begin
@@ -1193,6 +1230,8 @@ begin
LSite := Trim(LEntry.GetValue<string>('site', '')); LSite := Trim(LEntry.GetValue<string>('site', ''));
LTitle := Trim(LEntry.GetValue<string>('title', '')); LTitle := Trim(LEntry.GetValue<string>('title', ''));
LUser := Trim(LEntry.GetValue<string>('username', '')); LUser := Trim(LEntry.GetValue<string>('username', ''));
LUserEnc := LEntry.GetValue<string>('username_enc', '');
LUserIv := LEntry.GetValue<string>('username_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', '');
@@ -1227,6 +1266,8 @@ 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;
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;
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;
+8
View File
@@ -353,6 +353,14 @@ begin
// columns as opaque ciphertext + IV. // columns as opaque ciphertext + IV.
AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT'); AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT');
AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT'); AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT');
// Encrypted username (AES-GCM under the vault key, same as encrypted_password).
// Metadata-at-rest hardening (CODE_AUDIT §1.3): the cleartext `username`
// column is phased out — new writes store '' there and put the ciphertext
// here. Old rows keep cleartext until the client migration sweep re-encrypts
// them at unlock. Search/sort stay client-side on the decrypted in-memory
// value, so no server-side change to those. NULL = not yet encrypted.
AddColumnIfMissing('vault_entries', 'username_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'username_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.
+4 -2
View File
@@ -454,7 +454,9 @@ async function encryptImportEntry(plain) {
const tagsStr = Array.isArray(plain.tags) const tagsStr = Array.isArray(plain.tags)
? plain.tags.filter(Boolean).join(',') ? plain.tags.filter(Boolean).join(',')
: (plain.tags || ''); : (plain.tags || '');
return { // 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({
uuid: plain.uuid || '', uuid: plain.uuid || '',
site: plain.site, site: plain.site,
title: plain.title || '', title: plain.title || '',
@@ -470,7 +472,7 @@ async function encryptImportEntry(plain) {
custom_fields_iv: cfIv, custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '', icon_b64: plain.icon_b64 || '',
template: plain.template || '', template: plain.template || '',
}; });
} }
// Open a hidden file picker, route the result through the right parser, // Open a hidden file picker, route the result through the right parser,
+104 -17
View File
@@ -1468,10 +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).
// 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) {
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;
}
}
}
// 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 = '';
}
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() {
if (!state.cryptoKey) return;
const todo = state.entries.filter(e => e && !e.username_enc && (e.username || '') !== '');
if (todo.length === 0) return;
let migrated = 0;
for (const e of todo) {
try {
await api('/entries/' + e.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder: e.folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
template: e.template || '',
})),
});
migrated++;
} catch (_) { /* retry next unlock */ }
}
if (migrated > 0) await loadEntries();
}
async function loadEntries() { 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);
} 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();
@@ -1484,6 +1556,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);
state.trashedCount = state.trashed.length; state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; } } catch (e) { state.trashed = []; }
} }
@@ -2148,7 +2221,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({ body: JSON.stringify(await withEncryptedUsername({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -2161,7 +2234,7 @@ async function addTagToEntry(id, tag) {
totp_iv: e.totp_iv || '', totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '', custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '', custom_fields_iv: e.custom_fields_iv || '',
}), })),
}); });
e.tags = tags.join(','); e.tags = tags.join(',');
render(); render();
@@ -3423,11 +3496,11 @@ 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({ body: JSON.stringify(await withEncryptedUsername({
// Re-ship the full payload — partial PUT would wipe // Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template (see also // TOTP / custom_fields / kind / template / username_enc
// moveEntryToFolder and the "places à toucher" list // (see also moveEntryToFolder and the "places à toucher"
// in CLAUDE.md). // list in CLAUDE.md).
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -3440,7 +3513,7 @@ async function batchMoveToFolder(folder) {
totp_iv: e.totp_iv || '', totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '', custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '', custom_fields_iv: e.custom_fields_iv || '',
}), })),
}); });
e.folder = folder; e.folder = folder;
} catch (err) { /* ignore individual failures */ } } catch (err) { /* ignore individual failures */ }
@@ -3464,7 +3537,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({ body: JSON.stringify(await withEncryptedUsername({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -3477,7 +3550,7 @@ async function batchAddTag(tag) {
totp_iv: e.totp_iv || '', totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '', custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '', custom_fields_iv: e.custom_fields_iv || '',
}), })),
}); });
e.tags = tags.join(','); e.tags = tags.join(',');
} catch (err) {} } catch (err) {}
@@ -4665,7 +4738,7 @@ async function soSave() {
cfIv = e.iv; cfIv = e.iv;
} }
const body = JSON.stringify({ const body = JSON.stringify(await withEncryptedUsername({
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,
@@ -4673,7 +4746,7 @@ async function soSave() {
folder: fold, tags: soState.tags.join(','), folder: fold, tags: soState.tags.join(','),
kind, kind,
template: soState.template || '', template: soState.template || '',
}); }));
try { try {
let targetId = soState.id; let targetId = soState.id;
@@ -4993,10 +5066,10 @@ 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({ const body = JSON.stringify(await withEncryptedUsername({
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,
}); }));
try { try {
let savedId = id ? parseInt(id) : null; let savedId = id ? parseInt(id) : null;
if (id) { if (id) {
@@ -5089,7 +5162,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({ body: JSON.stringify(await withEncryptedUsername({
site: entry.site || '', site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)', title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '', username: entry.username || '',
@@ -5114,7 +5187,7 @@ async function duplicateEntry(entry) {
// Carry the template identifier so the copy keeps the // Carry the template identifier so the copy keeps the
// same card / table label as the source. // same card / table label as the source.
template: entry.template || '', template: entry.template || '',
}), })),
}); });
// Copy attachments. The source's encrypted blobs are keyed to the // Copy attachments. The source's encrypted blobs are keyed to the
@@ -5253,7 +5326,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({ body: JSON.stringify(await withEncryptedUsername({
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -5266,7 +5339,7 @@ async function moveEntryToFolder(id, folder) {
totp_iv: e.totp_iv || '', totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '', custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '', custom_fields_iv: e.custom_fields_iv || '',
}), })),
}); });
e.folder = folder; e.folder = folder;
render(); render();
@@ -7096,6 +7169,14 @@ async function doChangeMasterPassword() {
cfIv = c.iv; 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.push({ encrypted.push({
id: e.id, id: e.id,
encrypted_password: re.encrypted, encrypted_password: re.encrypted,
@@ -7104,6 +7185,8 @@ 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,
username_iv: uIv,
}); });
} finally { } finally {
state.cryptoKey = oldKey; // restore until server confirms state.cryptoKey = oldKey; // restore until server confirms
@@ -7877,6 +7960,10 @@ async function enterApp() {
await loadEntryCounts(); await loadEntryCounts();
render(); render();
resetAutoLock(); resetAutoLock();
// 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();
// 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();
+6
View File
@@ -110,6 +110,12 @@ test('merge: remote-only entry is added locally, keeping its uuid', async () =>
assert.equal(res.updated, 0); assert.equal(res.updated, 0);
assert.equal(db.entries.length, 1); assert.equal(db.entries.length, 1);
assert.equal(db.entries[0].uuid, 'uuid-new'); assert.equal(db.entries[0].uuid, 'uuid-new');
// Metadata-at-rest: username is encrypted on the way in — the stored row
// carries ciphertext + a blank cleartext field, never the plaintext.
assert.equal(db.entries[0].username, '', 'cleartext username must be blanked');
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'); assert.equal(db.entries[0].site, 'https://new.example');
}); });