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
+110 -51
View File
@@ -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;