feat(assets): brouiller les blocs embarques dans l'exe

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>
This commit is contained in:
r-zakarya
2026-09-05 17:17:15 +01:00
parent 8f0bbb580f
commit 427d294b22
4 changed files with 195 additions and 13 deletions
+99 -12
View File
@@ -36,7 +36,78 @@ function TryServeEmbedded(ARequest: TIdHTTPRequestInfo;
implementation
uses
Winapi.Windows;
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
@@ -86,28 +157,44 @@ 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);
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
try
// Copy into a TMemoryStream so Indy can own and free it after the response.
LMS := TMemoryStream.Create;
LMS.CopyFrom(LStream, 0);
LMS.Position := 0;
AResponse.ContentStream := LMS;
AResponse.FreeContentStream := True;
finally
LStream.Free;
end;
// 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;