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:
@@ -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<string>('totp_iv', '');
|
||||
var LCf := LEntry.GetValue<string>('custom_fields', '');
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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<string>('site', ''));
|
||||
LTitle := Trim(LBody.GetValue<string>('title', ''));
|
||||
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'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('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<string>('site', ''));
|
||||
LTitle := Trim(LBody.GetValue<string>('title', ''));
|
||||
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'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('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<string>('site', ''));
|
||||
LTitle := Trim(LEntry.GetValue<string>('title', ''));
|
||||
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'));
|
||||
LEnc := LEntry.GetValue<string>('encrypted_password', '');
|
||||
LIV := LEntry.GetValue<string>('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;
|
||||
|
||||
Reference in New Issue
Block a user