427d294b22
Le frontend etait lisible dans l'onglet Image de Process Explorer, avec les commentaires. BuildAssets.ps1 brouille chaque fichier avant brcc32, PM.EmbeddedAssets le debrouille au moment de servir. C'est de l'opacite, pas du secret : la cle est dans l'exe. Ca supprime strings et l'onglet Image ; la memoire, un debogueur et la reponse HTTP rendent toujours le texte. D'ou XOR plutot qu'AES — a securite egale (nulle contre qui a l'exe), dix lignes, et aucune force affichee qu'on n'a pas. Une magie PMA1 prefixe le clair : sans elle, deux constantes qui derivent serviraient du binaire au navigateur, ecran blanc sans trace. Verifie en comparant les flux des deux cotes bloc par bloc, puis par aller-retour sur app.js, index.html et style.css. Le frontend ne se trouve plus dans le binaire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
6.8 KiB
ObjectPascal
203 lines
6.8 KiB
ObjectPascal
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.
|