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>
267 lines
11 KiB
PowerShell
267 lines
11 KiB
PowerShell
# BuildAssets.ps1
|
|
# Generates assets.rc + assets.res containing the static web assets
|
|
# (index.html, js/*, css/*) so they can be linked into PMServer.exe as
|
|
# Win32 RCDATA resources.
|
|
#
|
|
# Workflow:
|
|
# 1. Edit Z:\password-manager\index.html / js\app.js / css\style.css
|
|
# 2. Run this script (or set it as pre-build event in Delphi)
|
|
# 3. Build PMServer.dpr in Delphi
|
|
# 4. Run - exe is autonomous, no external files needed at runtime
|
|
#
|
|
# Resource naming: URL path '/js/app.js' -> resource 'JS_APP_JS'
|
|
# - strip leading '/'
|
|
# - replace '/' '\' '.' '-' with '_'
|
|
# - uppercase
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $WebRoot = '',
|
|
[string] $OutRC = '',
|
|
[string] $OutRES = '',
|
|
[string] $OutInc = ''
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Resolve $PSScriptRoot — may be empty depending on invocation context.
|
|
# Fallback to the script's own file path.
|
|
$ScriptDir = $PSScriptRoot
|
|
if (-not $ScriptDir) { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition }
|
|
if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path }
|
|
|
|
# Apply param defaults now that we have a valid script dir
|
|
if (-not $WebRoot) { $WebRoot = Join-Path $ScriptDir '..\..' }
|
|
if (-not $OutRC) { $OutRC = Join-Path $ScriptDir 'assets.rc' }
|
|
if (-not $OutRES) { $OutRES = Join-Path $ScriptDir 'assets.res' }
|
|
if (-not $OutInc) { $OutInc = Join-Path $ScriptDir 'assets.inc' }
|
|
|
|
# All output goes to stdout/stderr. When invoked via BuildAssets.cmd the
|
|
# wrapper captures both streams into build.log; when run interactively
|
|
# everything shows in the terminal.
|
|
|
|
function Log { param([string] $msg) Write-Host $msg }
|
|
|
|
try {
|
|
|
|
$WebRoot = (Resolve-Path $WebRoot).Path
|
|
Log "Web root: $WebRoot"
|
|
|
|
# Whitelist patterns (relative to WebRoot). Add more here when needed.
|
|
# Use exact paths (not wildcards) to avoid embedding *-legacy.* backups.
|
|
$patterns = @(
|
|
'index.html',
|
|
'js\argon2.js',
|
|
'js\app.crypto.js',
|
|
'js\app.totp.js',
|
|
'js\app.favicon.js',
|
|
'js\app.import.js',
|
|
'js\app.backup.js',
|
|
'js\app.health.js',
|
|
'js\app.overlays.js',
|
|
'js\app.attachments.js',
|
|
'js\app.autofill.js',
|
|
'js\app.js',
|
|
'js\app.unlock.js',
|
|
'js\app.sync.js',
|
|
'css\style.css'
|
|
)
|
|
|
|
# Discover files
|
|
$files = @()
|
|
foreach ($p in $patterns) {
|
|
$found = Get-ChildItem -Path (Join-Path $WebRoot $p) -File -ErrorAction SilentlyContinue
|
|
foreach ($f in $found) {
|
|
$rel = $f.FullName.Substring($WebRoot.Length).TrimStart('\','/')
|
|
$files += [pscustomobject]@{
|
|
FullPath = $f.FullName
|
|
Relative = $rel
|
|
UrlPath = '/' + ($rel -replace '\\','/')
|
|
ResName = ($rel -replace '[\\/\.\-]','_').ToUpperInvariant()
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($files.Count -eq 0) {
|
|
throw "No assets found under $WebRoot. Check the patterns."
|
|
}
|
|
|
|
Log "Embedding $($files.Count) file(s):"
|
|
$files | ForEach-Object { Log (" " + $_.UrlPath + " -> " + $_.ResName) }
|
|
|
|
# --- JS syntax gate -----------------------------------------------------------
|
|
# A syntax error in app.js parses fine here but kills the whole frontend at
|
|
# runtime (no event handlers → dead UI), and it's only caught after a full
|
|
# Delphi rebuild. Run `node --check` on every embedded .js so a broken bundle
|
|
# never makes it into assets.res. Node is optional: if it isn't installed we
|
|
# warn and continue rather than blocking the build on a machine without it.
|
|
$node = Get-Command node.exe -ErrorAction SilentlyContinue
|
|
if (-not $node) { $node = Get-Command node -ErrorAction SilentlyContinue }
|
|
$jsFiles = $files | Where-Object { $_.Relative -match '\.js$' }
|
|
if ($jsFiles) {
|
|
if ($node) {
|
|
# The unit suite concatenates + parses EVERY embedded app.*.js (plus
|
|
# argon2.js) inside a vm, so a syntax error already fails it. When the
|
|
# suite runs we skip the per-file `node --check` loop — it was ~10 cold
|
|
# node process starts (~3-4s of dead build time) checking what the tests
|
|
# re-parse anyway. We only fall back to node --check when the suite is
|
|
# bypassed (PM_SKIP_TESTS) or absent.
|
|
# ponytail: assumes every embedded .js is an APP_PARTS module (or
|
|
# argon2.js). CLAUDE.md §3.1 already requires adding a new module to
|
|
# APP_PARTS + this whitelist together, so the assumption holds by process.
|
|
$haveTests = Test-Path (Join-Path $WebRoot 'js\tests')
|
|
$runTests = ($env:PM_SKIP_TESTS -ne '1') -and $haveTests
|
|
|
|
if ($runTests) {
|
|
# --- Unit test gate (also covers JS syntax) -----------------------
|
|
# crypto round-trip, CSV import, sync-merge arbitration, metadata.
|
|
# A broken invariant OR a syntax error blocks the build here instead
|
|
# of surfacing only after a full Delphi rebuild. Zero deps (node:test).
|
|
Log "Running frontend unit tests (also covers JS syntax)..."
|
|
Push-Location $WebRoot
|
|
try {
|
|
$testOut = $null | & $node.Source --test 'js/tests/**/*.test.js' 2>&1
|
|
$testExit = $LASTEXITCODE
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
if ($testExit -ne 0) {
|
|
Log "UNIT TESTS FAILED:"
|
|
Log ($testOut | Out-String)
|
|
throw "Frontend unit tests failed - aborting asset build. (Set PM_SKIP_TESTS=1 to bypass.)"
|
|
}
|
|
Log "Unit tests OK."
|
|
} else {
|
|
# No test gate this run → syntax-check each file individually so a
|
|
# broken bundle can never reach assets.res.
|
|
foreach ($jf in $jsFiles) {
|
|
Log "Syntax check: $($jf.Relative)"
|
|
# --check prints errors to stderr, returns non-zero on failure.
|
|
# Pipe $null into node so it can NEVER block on stdin (some
|
|
# Windows node shims read stdin from a non-interactive pre-build
|
|
# event, which would hang the build).
|
|
$out = $null | & $node.Source --check $jf.FullPath 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Log "JS SYNTAX ERROR in $($jf.Relative):"
|
|
Log ($out | Out-String)
|
|
throw "JS syntax check failed for $($jf.Relative) - aborting asset build."
|
|
}
|
|
}
|
|
Log "JS syntax OK."
|
|
if ($env:PM_SKIP_TESTS -eq '1') { Log "PM_SKIP_TESTS=1 - skipping unit tests." }
|
|
}
|
|
} else {
|
|
Log "WARNING: node not found - skipping JS syntax check + unit tests. Install Node to enable them."
|
|
}
|
|
}
|
|
|
|
# --- 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.')
|
|
[void]$rc.AppendLine('#pragma code_page(65001)')
|
|
[void]$rc.AppendLine('')
|
|
foreach ($f in $files) {
|
|
# brcc32 accepts forward slashes; backslashes need to be doubled in C strings
|
|
$rcPath = $f.ObfPath -replace '\\','\\'
|
|
[void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"")
|
|
}
|
|
[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII)
|
|
Log "Wrote $OutRC"
|
|
|
|
# --- Generate assets.inc (Pascal include with manifest) -----------------------
|
|
# Compile-time mapping URL -> resource name, consumed by PM.EmbeddedAssets.pas.
|
|
$nl = [Environment]::NewLine
|
|
$inc = New-Object System.Text.StringBuilder
|
|
[void]$inc.Append('// Auto-generated by BuildAssets.ps1 - do not edit by hand.' + $nl)
|
|
[void]$inc.Append('const' + $nl)
|
|
[void]$inc.Append(' EMBEDDED_ASSET_COUNT = ' + $files.Count + ';' + $nl)
|
|
[void]$inc.Append(' EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (' + $nl)
|
|
for ($i = 0; $i -lt $files.Count; $i++) {
|
|
$f = $files[$i]
|
|
if ($i -eq $files.Count - 1) { $sep = '' } else { $sep = ',' }
|
|
$line = " (UrlPath: '" + $f.UrlPath + "'; ResName: '" + $f.ResName + "')" + $sep + $nl
|
|
[void]$inc.Append($line)
|
|
}
|
|
[void]$inc.Append(' );' + $nl)
|
|
[System.IO.File]::WriteAllText($OutInc, $inc.ToString(), [System.Text.Encoding]::UTF8)
|
|
Log "Wrote $OutInc"
|
|
|
|
# --- Compile to .res via brcc32 ----------------------------------------------
|
|
$brcc = $null
|
|
if ($env:BDS) {
|
|
$candidate = Join-Path $env:BDS 'bin\brcc32.exe'
|
|
if (Test-Path $candidate) { $brcc = $candidate }
|
|
}
|
|
if (-not $brcc) {
|
|
$cmd = Get-Command brcc32.exe -ErrorAction SilentlyContinue
|
|
if ($cmd) { $brcc = $cmd.Source }
|
|
}
|
|
if (-not $brcc) {
|
|
throw "brcc32.exe not found. Set the BDS environment variable to your Delphi install root, or add brcc32.exe to PATH."
|
|
}
|
|
Log "Using $brcc"
|
|
|
|
& $brcc -32 -fo "$OutRES" "$OutRC"
|
|
if ($LASTEXITCODE -ne 0) { throw "brcc32 failed with exit code $LASTEXITCODE" }
|
|
Log "Wrote $OutRES"
|
|
Log "OK"
|
|
|
|
} catch {
|
|
Log "FATAL: $($_.Exception.Message)"
|
|
Log "Stack: $($_.ScriptStackTrace)"
|
|
exit 1
|
|
}
|