diff --git a/CLAUDE.md b/CLAUDE.md index 7bec40c..9a49076 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** : `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, -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 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** mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement sur certaines actions : diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 6b80146..3e72fe1 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -85,12 +85,19 @@ 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` : `site`, `title`, `username`, `folder`, `tags`, `kind`, - `template` **non chiffrés** (nécessaire pour recherche/tri sans déchiffrer) +- `vault_entries` : ~~`username`~~ **chiffré (2026-07-08)** ; `site`, `title`, + `folder`, `tags`, `kind`, `template` encore en clair. -Un attaquant avec accès disque voit la liste des sites et usernames. Pour -un vault perso c'est un compromis acceptable (recherche instantanée), mais -à documenter clairement pour l'utilisateur. +**`username` chiffré au repos (✅ 2026-07-08)** : colonnes +`username_enc/username_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. **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 diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index 39bb040..ffb831b 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -1040,6 +1040,7 @@ begin ' encrypted_password = :ep, iv = :iv, ' + ' totp_secret = :ts, totp_iv = :tiv, ' + ' custom_fields = :cf, custom_fields_iv = :cfiv, ' + + ' username_enc = :uenc, username_iv = :uiv, ' + ' updated_at = CURRENT_TIMESTAMP ' + 'WHERE id = :id AND user_id = :uid'; @@ -1053,6 +1054,8 @@ begin LTotpIv := LEntry.GetValue('totp_iv', ''); var LCf := LEntry.GetValue('custom_fields', ''); var LCfIv := LEntry.GetValue('custom_fields_iv', ''); + var LUEnc := LEntry.GetValue('username_enc', ''); + var LUIv := LEntry.GetValue('username_iv', ''); if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); @@ -1074,6 +1077,12 @@ begin else LQ.ParamByName('cf').Value := LCf; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; + LQ.ParamByName('uenc').DataType := ftMemo; + LQ.ParamByName('uiv').DataType := ftMemo; + if LUEnc = '' then LQ.ParamByName('uenc').Clear + else LQ.ParamByName('uenc').Value := LUEnc; + if LUIv = '' then LQ.ParamByName('uiv').Clear + else LQ.ParamByName('uiv').Value := LUIv; LQ.ExecSQL; end; // Password history is encrypted with the OLD vault key — we diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index d1dc5cb..e182319 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -88,7 +88,9 @@ begin LQ.SQL.Text := 'SELECT * FROM vault_entries ' + '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'; LQ.ParamByName('q').AsString := '%' + LSearch + '%'; end @@ -108,7 +110,20 @@ begin LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); LObj.AddPair('site', LQ.FieldByName('site').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); + // 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('iv', LQ.FieldByName('iv').AsString); LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString); @@ -300,8 +315,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LNewId: Integer; LBody, LObj: TJSONObject; - LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, - LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; + LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, + LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LQ: TFDQuery; begin try @@ -316,6 +331,11 @@ begin LSite := Trim(LBody.GetValue('site', '')); LTitle := Trim(LBody.GetValue('title', '')); LUser := Trim(LBody.GetValue('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('username_enc', ''); + LUserIv := LBody.GetValue('username_iv', ''); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); LIV := LBody.GetValue('iv', ''); @@ -359,15 +379,22 @@ begin LQ.Connection := DB.Connection; LQ.SQL.Text := '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,' + ' 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)'; 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; LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('f').AsString := LFolder; @@ -439,8 +466,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LId: Integer; LBody: TJSONObject; - LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, - LKind, LCf, LCfIv, LTemplate: string; + LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, + LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate: string; LHasTemplate: Boolean; LQ: TFDQuery; begin @@ -463,6 +490,8 @@ begin LSite := Trim(LBody.GetValue('site', '')); LTitle := Trim(LBody.GetValue('title', '')); LUser := Trim(LBody.GetValue('username', '')); + LUserEnc := LBody.GetValue('username_enc', ''); + LUserIv := LBody.GetValue('username_iv', ''); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); LIV := LBody.GetValue('iv', ''); @@ -532,7 +561,8 @@ begin if LHasTemplate then LTemplateSet := ', template=:tpl'; LQ.SQL.Text := '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, ' + ' custom_fields=:cf, custom_fields_iv=:cfiv, ' + ' updated_at=:c, ' + @@ -543,6 +573,10 @@ 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; LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('f').AsString := LFolder; @@ -1113,8 +1147,8 @@ var LUserId, I, LImported, LNewId: Integer; LBody, LObj, LEntry: TJSONObject; LArr, LIds: TJSONArray; - LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, - LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; + LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, + LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LQ, LTomb: TFDQuery; begin try @@ -1167,10 +1201,11 @@ begin 'WHERE user_id = :uid AND uuid = :uuid'; LQ.SQL.Text := '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,' + ' 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)'; // Declare optional param types ONCE — the prepared statement is // reused across every imported entry, and FireDAC needs the @@ -1186,6 +1221,8 @@ begin LQ.ParamByName('cfiv').DataType := ftMemo; LQ.ParamByName('ic').DataType := ftMemo; LQ.ParamByName('tpl').DataType := ftString; + LQ.ParamByName('uenc').DataType := ftMemo; + LQ.ParamByName('uiv').DataType := ftMemo; for I := 0 to LArr.Count - 1 do begin @@ -1193,6 +1230,8 @@ begin LSite := Trim(LEntry.GetValue('site', '')); LTitle := Trim(LEntry.GetValue('title', '')); LUser := Trim(LEntry.GetValue('username', '')); + LUserEnc := LEntry.GetValue('username_enc', ''); + LUserIv := LEntry.GetValue('username_iv', ''); LFolder := Trim(LEntry.GetValue('folder', 'All')); LEnc := LEntry.GetValue('encrypted_password', ''); LIV := LEntry.GetValue('iv', ''); @@ -1227,6 +1266,8 @@ begin 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; 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 d251efb..59977b7 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -353,6 +353,14 @@ begin // columns as opaque ciphertext + IV. AddColumnIfMissing('vault_entries', 'custom_fields', '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,..."). // 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 9ddb56e..d6b8e5b 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -454,7 +454,9 @@ async function encryptImportEntry(plain) { const tagsStr = Array.isArray(plain.tags) ? plain.tags.filter(Boolean).join(',') : (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 || '', site: plain.site, title: plain.title || '', @@ -470,7 +472,7 @@ async function encryptImportEntry(plain) { custom_fields_iv: cfIv, icon_b64: plain.icon_b64 || '', template: plain.template || '', - }; + }); } // Open a hidden file picker, route the result through the right parser, diff --git a/js/app.js b/js/app.js index e6f5aaa..08baa44 100644 --- a/js/app.js +++ b/js/app.js @@ -1468,10 +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) { + 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() { try { const r = await api('/entries', { headers: authHeaders() }); state.entries = Array.isArray(r) ? r : []; + await decryptEntryUsernames(state.entries); } catch (e) { if (e.message === 'Invalid session' || e.message === 'Session expired') { return doLogout(); @@ -1484,6 +1556,7 @@ async function loadTrash() { try { const r = await api('/entries?deleted=1', { headers: authHeaders() }); state.trashed = Array.isArray(r) ? r : []; + await decryptEntryUsernames(state.trashed); state.trashedCount = state.trashed.length; } catch (e) { state.trashed = []; } } @@ -2148,7 +2221,7 @@ async function addTagToEntry(id, tag) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + body: JSON.stringify(await withEncryptedUsername({ site: e.site, title: e.title || '', username: e.username, @@ -2161,7 +2234,7 @@ async function addTagToEntry(id, tag) { totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', - }), + })), }); e.tags = tags.join(','); render(); @@ -3423,11 +3496,11 @@ async function batchMoveToFolder(folder) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + body: JSON.stringify(await withEncryptedUsername({ // Re-ship the full payload — partial PUT would wipe - // TOTP / custom_fields / kind / template (see also - // moveEntryToFolder and the "places à toucher" list - // in CLAUDE.md). + // TOTP / custom_fields / kind / template / username_enc + // (see also moveEntryToFolder and the "places à toucher" + // list in CLAUDE.md). site: e.site, title: e.title || '', username: e.username, @@ -3440,7 +3513,7 @@ async function batchMoveToFolder(folder) { totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', - }), + })), }); e.folder = folder; } catch (err) { /* ignore individual failures */ } @@ -3464,7 +3537,7 @@ async function batchAddTag(tag) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + body: JSON.stringify(await withEncryptedUsername({ site: e.site, title: e.title || '', username: e.username, @@ -3477,7 +3550,7 @@ async function batchAddTag(tag) { totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', - }), + })), }); e.tags = tags.join(','); } catch (err) {} @@ -4665,7 +4738,7 @@ async function soSave() { cfIv = e.iv; } - const body = JSON.stringify({ + const body = JSON.stringify(await withEncryptedUsername({ site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, @@ -4673,7 +4746,7 @@ async function soSave() { folder: fold, tags: soState.tags.join(','), kind, template: soState.template || '', - }); + })); try { let targetId = soState.id; @@ -4993,10 +5066,10 @@ async function saveEntry(e) { if (!site || !pwd) return toast('Site and password required', 'error'); 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, folder: fold, tags, - }); + })); try { let savedId = id ? parseInt(id) : null; if (id) { @@ -5089,7 +5162,7 @@ async function duplicateEntry(entry) { const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + body: JSON.stringify(await withEncryptedUsername({ site: entry.site || '', title: entryDisplayName(entry) + ' (copy)', username: entry.username || '', @@ -5114,7 +5187,7 @@ async function duplicateEntry(entry) { // Carry the template identifier so the copy keeps the // same card / table label as the source. template: entry.template || '', - }), + })), }); // Copy attachments. The source's encrypted blobs are keyed to the @@ -5253,7 +5326,7 @@ async function moveEntryToFolder(id, folder) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + body: JSON.stringify(await withEncryptedUsername({ site: e.site, title: e.title || '', username: e.username, @@ -5266,7 +5339,7 @@ async function moveEntryToFolder(id, folder) { totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', - }), + })), }); e.folder = folder; render(); @@ -7096,6 +7169,14 @@ 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.push({ id: e.id, encrypted_password: re.encrypted, @@ -7104,6 +7185,8 @@ async function doChangeMasterPassword() { totp_iv: totpIv, custom_fields: cfEnc, custom_fields_iv: cfIv, + username_enc: uEnc, + username_iv: uIv, }); } finally { state.cryptoKey = oldKey; // restore until server confirms @@ -7877,6 +7960,10 @@ async function enterApp() { await loadEntryCounts(); render(); 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, // 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 c8223e2..17a6514 100644 --- a/js/tests/merge.test.js +++ b/js/tests/merge.test.js @@ -110,6 +110,12 @@ test('merge: remote-only entry is added locally, keeping its uuid', async () => assert.equal(res.updated, 0); assert.equal(db.entries.length, 1); 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'); });