unit PM.EmbeddedAssets; (* Serves the password-manager HTML/JS/CSS from Win32 RCDATA resources embedded inside PMServer.exe by BuildAssets.ps1. Workflow: 1. Edit ../../index.html, ../../js/*.js, ../../css/*.css 2. Run delphi-backend/assets/BuildAssets.ps1 3. Rebuild — exe ships self-contained 4. Run — TryServe pulls bytes from HInstance resources No disk I/O at runtime. No external files needed beside PMServer.exe. The manifest (URL path -> resource name) is generated alongside the .res: delphi-backend/assets/assets.inc, included below via {$I}. If the file does not exist (BuildAssets.ps1 never ran), the compile-time fallback registers no assets and TryServe always returns False. *) interface uses System.SysUtils, System.Classes, IdCustomHTTPServer; type TEmbeddedAsset = record UrlPath: string; ResName: string; end; function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo): Boolean; implementation uses Winapi.Windows, System.Hash; // ===== Brouillage des blocs embarques ======================================== // // CE N'EST PAS DU SECRET, C'EST DE L'OPACITE, et il faut le dire pour ne pas // s'en croire protege. La cle est forcement dans l'exe, a quelques centaines // d'octets d'ici : quiconque tient le binaire peut retrouver le texte. Ce que // ca supprime, c'est l'onglet "Image" de Process Explorer et n'importe quel // `strings` — le frontend n'est plus lisible en survolant le fichier. L'onglet // "Memory", un debogueur, ou simplement la reponse HTTP le rendent toujours : // WebView2 doit recevoir du JavaScript en clair pour l'executer. // // D'ou le choix d'un flux XOR plutot que d'AES : a securite rigoureusement // egale (nulle, contre qui a l'exe), c'est dix lignes au lieu d'un appel CNG, // et surtout ca n'affiche pas une force qu'on n'a pas. // // Le flux depend du NOM DE LA RESSOURCE : deux fichiers qui commencent // pareil — et ils commencent tous par les memes lignes de commentaire — ne // produisent pas la meme sortie. const // Doit correspondre EXACTEMENT a $ObfSecret dans BuildAssets.ps1. // Les deux se desaccordent = magie invalide = 404 sur /index.html. AssetObfSecret: array[0..31] of Byte = ( $9A, $AC, $C6, $05, $DE, $D6, $FA, $E7, $4F, $95, $04, $E4, $2C, $1D, $DF, $26, $9B, $BA, $C9, $D2, $0A, $52, $D5, $90, $B1, $5E, $A8, $09, $12, $9B, $9E, $6C ); // Prefixe le clair AVANT brouillage. Sans lui, une cle qui a derive entre le // script et l'exe servirait du binaire au navigateur : ecran blanc, aucune // trace. Avec lui, on sait refuser et le dire. AssetMagic = 'PMA1'; // Le XOR est son propre inverse : la meme fonction brouille et debrouille. // C'est BuildAssets.ps1 qui tient l'autre moitie, et les deux doivent produire // le meme flux — d'ou le detail du format, ecrit ici une fois pour toutes : // // bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE ) // function DeobfuscateAsset(const AResName: string; const AData: TBytes): TBytes; var LSeed, LName, LBlock: TBytes; LSha: THashSHA2; LIdx: Cardinal; I, J: Integer; begin SetLength(Result, Length(AData)); LName := TEncoding.UTF8.GetBytes(AResName); SetLength(LSeed, 32 + Length(LName)); Move(AssetObfSecret[0], LSeed[0], 32); if Length(LName) > 0 then Move(LName[0], LSeed[32], Length(LName)); LIdx := 0; I := 0; while I < Length(AData) do begin LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); LSha.Update(LSeed); LSha.Update(LIdx, SizeOf(LIdx)); LBlock := LSha.HashAsBytes; for J := 0 to High(LBlock) do begin if I + J >= Length(AData) then Break; Result[I + J] := AData[I + J] xor LBlock[J]; end; Inc(I, Length(LBlock)); Inc(LIdx); end; end; // The manifest is auto-generated. A stub is checked in so the project // compiles before BuildAssets.ps1 ever runs; running the script overwrites // it with the real list. {$I ..\assets\assets.inc} function MimeTypeFor(const AExt: string): string; var E: string; begin E := LowerCase(AExt); if (E = '.html') or (E = '.htm') then Exit('text/html; charset=utf-8'); if E = '.js' then Exit('application/javascript; charset=utf-8'); if E = '.mjs' then Exit('application/javascript; charset=utf-8'); if E = '.css' then Exit('text/css; charset=utf-8'); if E = '.json' then Exit('application/json; charset=utf-8'); if E = '.svg' then Exit('image/svg+xml'); if E = '.png' then Exit('image/png'); if E = '.jpg' then Exit('image/jpeg'); if E = '.jpeg' then Exit('image/jpeg'); if E = '.gif' then Exit('image/gif'); if E = '.ico' then Exit('image/x-icon'); if E = '.woff' then Exit('font/woff'); if E = '.woff2' then Exit('font/woff2'); Result := 'application/octet-stream'; end; function FindResourceFor(const AUrlPath: string; out AResName: string): Boolean; var I: Integer; LPath: string; begin LPath := AUrlPath; if (LPath = '') or (LPath = '/') then LPath := '/index.html'; for I := 0 to EMBEDDED_ASSET_COUNT - 1 do if SameText(EMBEDDED_ASSETS[I].UrlPath, LPath) then begin AResName := EMBEDDED_ASSETS[I].ResName; Exit(True); end; Result := False; end; function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo): Boolean; var LResName, LExt: string; LStream: TResourceStream; LMS: TMemoryStream; LRaw, LPlain: TBytes; begin Result := False; if not SameText(ARequest.Command, 'GET') then Exit; if not FindResourceFor(ARequest.Document, LResName) then Exit; if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit; LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA); try SetLength(LRaw, LStream.Size); if LStream.Size > 0 then LStream.ReadBuffer(LRaw[0], LStream.Size); finally LStream.Free; end; LPlain := DeobfuscateAsset(LResName, LRaw); // La magie tombe = le script et l'exe n'ont pas la meme cle. Refuser vaut // mieux que servir du binaire : un 404 se voit dans le journal, un ecran // blanc ne dit rien. Symptome a reconnaitre : /index.html en 404 juste apres // avoir touche AssetObfSecret d'un seul cote. if (Length(LPlain) < Length(AssetMagic)) or (TEncoding.ANSI.GetString(Copy(LPlain, 0, Length(AssetMagic))) <> AssetMagic) then Exit(False); LExt := ExtractFileExt(ARequest.Document); if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then LExt := '.html'; AResponse.ContentType := MimeTypeFor(LExt); // Copy into a TMemoryStream so Indy can own and free it after the response. LMS := TMemoryStream.Create; if Length(LPlain) > Length(AssetMagic) then LMS.WriteBuffer(LPlain[Length(AssetMagic)], Length(LPlain) - Length(AssetMagic)); LMS.Position := 0; AResponse.ContentStream := LMS; AResponse.FreeContentStream := True; AResponse.ResponseNo := 200; Result := True; end; end.