diff --git a/.gitignore b/.gitignore index 2bf70bc..34e3b75 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,7 @@ android-client/web/*.res # Cle privee des licences. Qui la detient fabrique des licences valables : # elle vit dans un gestionnaire de mots de passe, pas dans un depot. tools/licence-private.key + +# Blocs brouilles intermediaires (BuildAssets.ps1). Regeneres a chaque build, +# comme assets.res : jetables, pas versionnes. +delphi-backend/assets/obf/ diff --git a/CLAUDE.md b/CLAUDE.md index 708a83b..867664d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,44 @@ Toute modif `index.html` / `js/` / `css/` nécessite : Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le plus courant après modif frontend. +### Blocs embarqués brouillés + +Depuis 2026-09-05, `BuildAssets.ps1` **brouille** chaque fichier avant de le +donner à `brcc32` (dans `assets/obf/`, gitignoré ; les sources en clair ne sont +jamais touchées) et `PM.EmbeddedAssets.DeobfuscateAsset` le débrouille au moment +de servir. + +``` +bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE ) +sortie = XOR( magie 'PMA1' + contenu du fichier ) +``` + +- **C'est de l'opacité, pas du secret.** La clé est dans l'exe : qui tient le + binaire retrouve le texte. Ce que ça 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 débogueur ou la réponse HTTP le + rendent toujours : WebView2 doit recevoir du JavaScript en clair pour + l'exécuter. D'où **XOR plutôt qu'AES** : à sécurité rigoureusement égale + (nulle, contre qui a l'exe), dix lignes au lieu d'un appel CNG, et surtout + aucune force affichée qu'on n'a pas. +- Le flux dépend du **nom de la ressource** : tous les fichiers commencent par + les mêmes lignes de commentaire, un flux commun rendrait ces préfixes + identiques d'un bloc à l'autre. +- **La magie `PMA1` préfixe le clair.** Sans elle, une clé qui a dérivé entre le + script et l'exe servirait du binaire au navigateur : écran blanc, aucune + trace. Avec elle, `TryServeEmbedded` refuse. **Symptôme à reconnaître : + `/index.html` en 404 juste après avoir touché `AssetObfSecret` d'un seul + côté** — les deux constantes (`$ObfSecret` / `AssetObfSecret`) doivent rester + identiques. +- **Coût mesuré : 2,45 ms** pour les 820 Ko embarqués (AES chronométré comme + borne haute ; le XOR est plus rapide), une seule fois au chargement de la + page. À comparer aux ~650 ms d'Argon2id au déverrouillage. +- **Les tests frontend ne sont pas concernés** : ils lisent les `.js` sur le + disque, jamais le `.res`. +- Vérifié en comparant les flux des deux côtés bloc par bloc (ils doivent être + identiques au bit près) puis en refaisant l'aller-retour sur `app.js`, + `index.html` et `style.css`. + ### Découpage frontend (§3.1, en cours) `app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script diff --git a/delphi-backend/Source/PM.EmbeddedAssets.pas b/delphi-backend/Source/PM.EmbeddedAssets.pas index 699f35c..24fd17a 100644 --- a/delphi-backend/Source/PM.EmbeddedAssets.pas +++ b/delphi-backend/Source/PM.EmbeddedAssets.pas @@ -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; diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index bce8bfb..cd88fa0 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -155,6 +155,59 @@ if ($jsFiles) { } } +# --- Brouillage des blocs embarques ------------------------------------------- +# +# CE N'EST PAS DU SECRET, C'EST DE L'OPACITE. La cle vit dans l'exe, donc qui +# tient le binaire retrouve 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. La memoire du processus, un debogueur +# ou la reponse HTTP le rendent toujours - WebView2 doit recevoir du +# JavaScript en clair pour l'executer. +# +# L'AUTRE MOITIE EST DANS PM.EmbeddedAssets.pas ET LES DEUX DOIVENT S'ACCORDER : +# +# bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE ) +# sortie = XOR( magie 'PMA1' + contenu du fichier ) +# +# La magie prefixe le CLAIR : elle permet a l'exe de reconnaitre une cle qui a +# derive et de refuser, au lieu de servir du binaire au navigateur (ecran blanc +# sans trace). Symptome d'un desaccord : /index.html en 404. +$ObfSecret = [byte[]]@( + 0x9A,0xAC,0xC6,0x05,0xDE,0xD6,0xFA,0xE7, + 0x4F,0x95,0x04,0xE4,0x2C,0x1D,0xDF,0x26, + 0x9B,0xBA,0xC9,0xD2,0x0A,0x52,0xD5,0x90, + 0xB1,0x5E,0xA8,0x09,0x12,0x9B,0x9E,0x6C +) +$ObfMagic = [Text.Encoding]::ASCII.GetBytes('PMA1') + +function Obfuscate([byte[]] $plain, [string] $resName) { + $sha = [System.Security.Cryptography.SHA256]::Create() + $seed = $ObfSecret + [Text.Encoding]::UTF8.GetBytes($resName) + $out = New-Object byte[] $plain.Length + $i = 0; $idx = 0 + while ($i -lt $plain.Length) { + $block = $sha.ComputeHash($seed + [BitConverter]::GetBytes([uint32]$idx)) + for ($j = 0; $j -lt $block.Length -and ($i + $j) -lt $plain.Length; $j++) { + $out[$i + $j] = $plain[$i + $j] -bxor $block[$j] + } + $i += $block.Length; $idx++ + } + return $out +} + +# Les blocs brouilles vont dans un dossier a part : brcc32 lit ce que le .rc +# lui montre, et on ne veut surtout pas ecraser les sources en clair. +$ObfDir = Join-Path $ScriptDir 'obf' +if (Test-Path $ObfDir) { Remove-Item $ObfDir -Recurse -Force } +New-Item -ItemType Directory -Path $ObfDir | Out-Null +foreach ($f in $files) { + $plain = $ObfMagic + [System.IO.File]::ReadAllBytes($f.FullPath) + $dest = Join-Path $ObfDir ($f.ResName + '.bin') + [System.IO.File]::WriteAllBytes($dest, (Obfuscate $plain $f.ResName)) + $f | Add-Member -NotePropertyName ObfPath -NotePropertyValue $dest -Force +} +Log "Brouille $($files.Count) bloc(s) dans $ObfDir" + # --- Generate assets.rc ------------------------------------------------------- $rc = New-Object System.Text.StringBuilder [void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.') @@ -162,7 +215,7 @@ $rc = New-Object System.Text.StringBuilder [void]$rc.AppendLine('') foreach ($f in $files) { # brcc32 accepts forward slashes; backslashes need to be doubled in C strings - $rcPath = $f.FullPath -replace '\\','\\' + $rcPath = $f.ObfPath -replace '\\','\\' [void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"") } [System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII)