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:
@@ -55,6 +55,26 @@ begin
|
||||
Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime);
|
||||
end;
|
||||
|
||||
// Emit a TEXT field as a JSON string, or JSON null when the column is NULL.
|
||||
// Used for the *_enc/*_iv encrypted-metadata columns so the client can tell
|
||||
// "not migrated yet" (null) from "encrypted, empty plaintext" (a string).
|
||||
procedure AddNullableField(AObj: TJSONObject; const AName: string; AField: TField);
|
||||
begin
|
||||
if AField.IsNull then
|
||||
AObj.AddPair(AName, TJSONNull.Create)
|
||||
else
|
||||
AObj.AddPair(AName, AField.AsString);
|
||||
end;
|
||||
|
||||
// Bind a TEXT param as NULL when empty, else the value (ftMemo so long
|
||||
// ciphertext isn't truncated). For the encrypted-metadata *_enc/*_iv params.
|
||||
procedure BindNullable(AQ: TFDQuery; const AParam, AValue: string);
|
||||
begin
|
||||
AQ.ParamByName(AParam).DataType := ftMemo;
|
||||
if AValue = '' then AQ.ParamByName(AParam).Clear
|
||||
else AQ.ParamByName(AParam).Value := AValue;
|
||||
end;
|
||||
|
||||
// ===== GET /entries ==========================================================
|
||||
|
||||
procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo;
|
||||
@@ -83,24 +103,14 @@ begin
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
if LSearch <> '' then
|
||||
begin
|
||||
LQ.SQL.Text :=
|
||||
'SELECT * FROM vault_entries ' +
|
||||
'WHERE user_id = :uid AND deleted = :del ' +
|
||||
// username is encrypted at rest → LIKE can't match it; the frontend
|
||||
// searches client-side on the decrypted vault anyway. Site only.
|
||||
'AND site LIKE :q ' +
|
||||
'ORDER BY updated_at DESC';
|
||||
LQ.ParamByName('q').AsString := '%' + LSearch + '%';
|
||||
end
|
||||
else
|
||||
begin
|
||||
LQ.SQL.Text :=
|
||||
'SELECT * FROM vault_entries ' +
|
||||
'WHERE user_id = :uid AND deleted = :del ' +
|
||||
'ORDER BY updated_at DESC';
|
||||
end;
|
||||
// The ?search= query param is now ignored server-side: site AND username
|
||||
// are both encrypted at rest, so a SQL LIKE can't match them. The
|
||||
// frontend loads the whole (decrypted) vault and filters client-side —
|
||||
// it never sends ?search=. Kept LSearch read for API back-compat only.
|
||||
LQ.SQL.Text :=
|
||||
'SELECT * FROM vault_entries ' +
|
||||
'WHERE user_id = :uid AND deleted = :del ' +
|
||||
'ORDER BY updated_at DESC';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('del').AsInteger := LDeleted;
|
||||
LQ.Open;
|
||||
@@ -124,6 +134,14 @@ begin
|
||||
LObj.AddPair('username_iv', TJSONNull.Create)
|
||||
else
|
||||
LObj.AddPair('username_iv', LQ.FieldByName('username_iv').AsString);
|
||||
// Encrypted site / title / tags — same scheme as username_enc. NULL →
|
||||
// JSON null so the client falls back to the cleartext siblings above.
|
||||
AddNullableField(LObj, 'site_enc', LQ.FieldByName('site_enc'));
|
||||
AddNullableField(LObj, 'site_iv', LQ.FieldByName('site_iv'));
|
||||
AddNullableField(LObj, 'title_enc', LQ.FieldByName('title_enc'));
|
||||
AddNullableField(LObj, 'title_iv', LQ.FieldByName('title_iv'));
|
||||
AddNullableField(LObj, 'tags_enc', LQ.FieldByName('tags_enc'));
|
||||
AddNullableField(LObj, 'tags_iv', LQ.FieldByName('tags_iv'));
|
||||
LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
|
||||
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
|
||||
LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString);
|
||||
@@ -316,7 +334,8 @@ var
|
||||
LUserId, LNewId: Integer;
|
||||
LBody, LObj: TJSONObject;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -336,6 +355,14 @@ begin
|
||||
// in username_enc/username_iv instead.
|
||||
LUserEnc := LBody.GetValue<string>('username_enc', '');
|
||||
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'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
@@ -358,17 +385,15 @@ begin
|
||||
|
||||
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
|
||||
|
||||
// 'login' entries require a site; 'note' only needs encrypted body.
|
||||
if LEnc = '' then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Content required');
|
||||
Exit;
|
||||
end;
|
||||
if (LKind = 'login') and (LSite = '') then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Site required');
|
||||
Exit;
|
||||
end;
|
||||
// NOTE: the old "Site required" check is gone — site is now encrypted at
|
||||
// rest (LSite is '' when the client sent site_enc), so the server can't
|
||||
// read it. The client already enforces "site + password required" before
|
||||
// saving a login.
|
||||
|
||||
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
|
||||
|
||||
@@ -380,21 +405,27 @@ begin
|
||||
LQ.SQL.Text :=
|
||||
'INSERT INTO vault_entries ' +
|
||||
'(user_id, site, title, username, username_enc, username_iv, ' +
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||
' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' +
|
||||
'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
|
||||
'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||
' :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
|
||||
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('s').AsString := LSite;
|
||||
LQ.ParamByName('tt').AsString := LTitle;
|
||||
LQ.ParamByName('u').AsString := LUser;
|
||||
// Encrypted username: NULL when not supplied (pre-migration client or a
|
||||
// row that genuinely has no username) so GET emits JSON null.
|
||||
LQ.ParamByName('uenc').DataType := ftMemo;
|
||||
LQ.ParamByName('uiv').DataType := ftMemo;
|
||||
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc;
|
||||
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv;
|
||||
// Encrypted metadata: NULL when not supplied (pre-migration client or a
|
||||
// row with no value) so GET emits JSON null and the client falls back.
|
||||
BindNullable(LQ, 'uenc', LUserEnc);
|
||||
BindNullable(LQ, 'uiv', LUserIv);
|
||||
BindNullable(LQ, 'senc', LSiteEnc);
|
||||
BindNullable(LQ, 'siv', LSiteIv);
|
||||
BindNullable(LQ, 'tenc', LTitleEnc);
|
||||
BindNullable(LQ, 'tiv2', LTitleIv);
|
||||
BindNullable(LQ, 'genc', LTagsEnc);
|
||||
BindNullable(LQ, 'giv', LTagsIv);
|
||||
LQ.ParamByName('e').AsString := LEnc;
|
||||
LQ.ParamByName('i').AsString := LIV;
|
||||
LQ.ParamByName('f').AsString := LFolder;
|
||||
@@ -467,7 +498,8 @@ var
|
||||
LUserId, LId: Integer;
|
||||
LBody: TJSONObject;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate: string;
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LHasTemplate: Boolean;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
@@ -492,6 +524,12 @@ begin
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LUserEnc := LBody.GetValue<string>('username_enc', '');
|
||||
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'));
|
||||
LEnc := LBody.GetValue<string>('encrypted_password', '');
|
||||
LIV := LBody.GetValue<string>('iv', '');
|
||||
@@ -516,11 +554,8 @@ begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Content required');
|
||||
Exit;
|
||||
end;
|
||||
if (LKind = 'login') and (LSite = '') then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Site required');
|
||||
Exit;
|
||||
end;
|
||||
// "Site required" removed — site is encrypted at rest (LSite is '' when the
|
||||
// client sent site_enc). The client enforces it before saving.
|
||||
|
||||
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
|
||||
DB.Lock;
|
||||
@@ -562,6 +597,8 @@ begin
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries ' +
|
||||
'SET site=:s, title=:tt, username=:u, username_enc=:uenc, username_iv=:uiv, ' +
|
||||
' site_enc=:senc, site_iv=:siv, title_enc=:tenc, title_iv=:tiv2, ' +
|
||||
' tags_enc=:genc, tags_iv=:giv, ' +
|
||||
' encrypted_password=:e, iv=:i, ' +
|
||||
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
|
||||
' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
|
||||
@@ -573,10 +610,14 @@ begin
|
||||
LQ.ParamByName('s').AsString := LSite;
|
||||
LQ.ParamByName('tt').AsString := LTitle;
|
||||
LQ.ParamByName('u').AsString := LUser;
|
||||
LQ.ParamByName('uenc').DataType := ftMemo;
|
||||
LQ.ParamByName('uiv').DataType := ftMemo;
|
||||
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc;
|
||||
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv;
|
||||
BindNullable(LQ, 'uenc', LUserEnc);
|
||||
BindNullable(LQ, 'uiv', LUserIv);
|
||||
BindNullable(LQ, 'senc', LSiteEnc);
|
||||
BindNullable(LQ, 'siv', LSiteIv);
|
||||
BindNullable(LQ, 'tenc', LTitleEnc);
|
||||
BindNullable(LQ, 'tiv2', LTitleIv);
|
||||
BindNullable(LQ, 'genc', LTagsEnc);
|
||||
BindNullable(LQ, 'giv', LTagsIv);
|
||||
LQ.ParamByName('e').AsString := LEnc;
|
||||
LQ.ParamByName('i').AsString := LIV;
|
||||
LQ.ParamByName('f').AsString := LFolder;
|
||||
@@ -1148,7 +1189,8 @@ var
|
||||
LBody, LObj, LEntry: TJSONObject;
|
||||
LArr, LIds: TJSONArray;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
|
||||
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
|
||||
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LQ, LTomb: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -1202,10 +1244,12 @@ begin
|
||||
LQ.SQL.Text :=
|
||||
'INSERT INTO vault_entries ' +
|
||||
'(user_id, site, title, username, username_enc, username_iv, ' +
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||
' icon_b64, template, uuid, created_at, updated_at) ' +
|
||||
'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
|
||||
'VALUES (:uid, :s, :tt, :u, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||
' :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
|
||||
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)';
|
||||
// Declare optional param types ONCE — the prepared statement is
|
||||
// reused across every imported entry, and FireDAC needs the
|
||||
@@ -1223,6 +1267,12 @@ begin
|
||||
LQ.ParamByName('tpl').DataType := ftString;
|
||||
LQ.ParamByName('uenc').DataType := ftMemo;
|
||||
LQ.ParamByName('uiv').DataType := ftMemo;
|
||||
LQ.ParamByName('senc').DataType := ftMemo;
|
||||
LQ.ParamByName('siv').DataType := ftMemo;
|
||||
LQ.ParamByName('tenc').DataType := ftMemo;
|
||||
LQ.ParamByName('tiv2').DataType := ftMemo;
|
||||
LQ.ParamByName('genc').DataType := ftMemo;
|
||||
LQ.ParamByName('giv').DataType := ftMemo;
|
||||
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
@@ -1232,6 +1282,12 @@ begin
|
||||
LUser := Trim(LEntry.GetValue<string>('username', ''));
|
||||
LUserEnc := LEntry.GetValue<string>('username_enc', '');
|
||||
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'));
|
||||
LEnc := LEntry.GetValue<string>('encrypted_password', '');
|
||||
LIV := LEntry.GetValue<string>('iv', '');
|
||||
@@ -1256,18 +1312,21 @@ begin
|
||||
LIds.AddElement(TJSONNumber.Create(-1));
|
||||
Continue;
|
||||
end;
|
||||
if (LKind = 'login') and (LSite = '') then
|
||||
begin
|
||||
LIds.AddElement(TJSONNumber.Create(-1));
|
||||
Continue;
|
||||
end;
|
||||
// No "site required" skip — site is encrypted (LSite = '' when the
|
||||
// row carries site_enc); the client validated before import.
|
||||
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('s').AsString := LSite;
|
||||
LQ.ParamByName('tt').AsString := LTitle;
|
||||
LQ.ParamByName('u').AsString := LUser;
|
||||
if LUserEnc = '' then LQ.ParamByName('uenc').Clear else LQ.ParamByName('uenc').Value := LUserEnc;
|
||||
if LUserIv = '' then LQ.ParamByName('uiv').Clear else LQ.ParamByName('uiv').Value := LUserIv;
|
||||
BindNullable(LQ, 'uenc', LUserEnc);
|
||||
BindNullable(LQ, 'uiv', LUserIv);
|
||||
BindNullable(LQ, 'senc', LSiteEnc);
|
||||
BindNullable(LQ, 'siv', LSiteIv);
|
||||
BindNullable(LQ, 'tenc', LTitleEnc);
|
||||
BindNullable(LQ, 'tiv2', LTitleIv);
|
||||
BindNullable(LQ, 'genc', LTagsEnc);
|
||||
BindNullable(LQ, 'giv', LTagsIv);
|
||||
LQ.ParamByName('e').AsString := LEnc;
|
||||
LQ.ParamByName('i').AsString := LIV;
|
||||
LQ.ParamByName('f').AsString := LFolder;
|
||||
|
||||
Reference in New Issue
Block a user