diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1ea4c69..e6f9eaf 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -31,7 +31,7 @@ jobs: --output dist/build \ -p:EnableWindowsTargeting=true \ -p:PublishSingleFile=true \ - -p:SelfContained=false + -p:SelfContained=true cp dist/build/MrTrust.exe dist/MrTrust.exe - name: Build release ZIP @@ -41,15 +41,9 @@ jobs: version="0.1.1" package_root="dist/MrTrust-${version}" rm -rf "$package_root" "dist/MrTrust-${version}.zip" - mkdir -p "$package_root/scripts" "$package_root/assets/certificates" "$package_root/docs" + mkdir -p "$package_root" cp dist/MrTrust.exe "$package_root/" - cp MrTrust.ps1 README.md "$package_root/" - cp assets/MrTrust.ico "$package_root/assets/" - cp scripts/Install-MrTrust.ps1 scripts/Uninstall-MrTrust.ps1 scripts/Start-MrTrustGui.ps1 "$package_root/scripts/" - cp assets/certificates/MrSphay-LocalTrust-Root.cer "$package_root/assets/certificates/" - cp assets/certificates/MrSphay-CodeSigning.cer "$package_root/assets/certificates/" - cp assets/certificates/thumbprints.txt "$package_root/assets/certificates/" - cp docs/security-model.md "$package_root/docs/" + cp README.md "$package_root/" (cd dist && zip -r "MrTrust-${version}.zip" "MrTrust-${version}") - name: Show package contents diff --git a/README.md b/README.md index 321b351..2c1d7cd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ MrTrust does not bypass Microsoft Defender or SmartScreen. Windows can still sca - `scripts/Sign-MrTrustProject.ps1` signs `.exe`, `.msi`, `.ps1`, and other Authenticode-compatible files. - `scripts/New-MrTrustRelease.ps1` builds a distributable ZIP package. - `docs/integration-prompt.md` is a prompt you can paste into other Windows projects. +- `MrTrust.exe` is standalone for normal users. It embeds the public certificates and runtime scripts. ## Quick Start For MrSphay @@ -72,18 +73,7 @@ The Gitea workflow `.gitea/workflows/build.yml` builds the Windows launcher EXE ## User Installation -For normal users, distribute MrTrust with the public certificate file: - -```text -assets\certificates\MrSphay-LocalTrust-Root.cer -assets\certificates\MrSphay-CodeSigning.cer -``` - -The user runs: - -```powershell -.\MrTrust.ps1 gui -``` +For normal users, distribute `MrTrust.exe`. The executable embeds the public certificate files and opens the GUI by default. By default, MrTrust installs trust only for the current Windows user: diff --git a/scripts/Build-MrTrustExe.ps1 b/scripts/Build-MrTrustExe.ps1 index d479496..380e46a 100644 --- a/scripts/Build-MrTrustExe.ps1 +++ b/scripts/Build-MrTrustExe.ps1 @@ -16,6 +16,21 @@ $sourcePath = Join-Path $root "src\MrTrustLauncher.cs" $iconPath = Join-Path $root "assets\MrTrust.ico" $resolvedOutputPath = Resolve-FullPath $OutputPath $outputDirectory = Split-Path -Parent $resolvedOutputPath +$payloadFiles = @( + @{ Path = "MrTrust.ps1"; ResourceName = "MrTrust.Payload.MrTrust.ps1" }, + @{ Path = "scripts\Build-MrTrustExe.ps1"; ResourceName = "MrTrust.Payload.scripts.Build-MrTrustExe.ps1" }, + @{ Path = "scripts\Install-MrTrust.ps1"; ResourceName = "MrTrust.Payload.scripts.Install-MrTrust.ps1" }, + @{ Path = "scripts\New-MrTrustCertificate.ps1"; ResourceName = "MrTrust.Payload.scripts.New-MrTrustCertificate.ps1" }, + @{ Path = "scripts\New-MrTrustIcon.ps1"; ResourceName = "MrTrust.Payload.scripts.New-MrTrustIcon.ps1" }, + @{ Path = "scripts\New-MrTrustRelease.ps1"; ResourceName = "MrTrust.Payload.scripts.New-MrTrustRelease.ps1" }, + @{ Path = "scripts\Sign-MrTrustProject.ps1"; ResourceName = "MrTrust.Payload.scripts.Sign-MrTrustProject.ps1" }, + @{ Path = "scripts\Start-MrTrustGui.ps1"; ResourceName = "MrTrust.Payload.scripts.Start-MrTrustGui.ps1" }, + @{ Path = "scripts\Uninstall-MrTrust.ps1"; ResourceName = "MrTrust.Payload.scripts.Uninstall-MrTrust.ps1" }, + @{ Path = "assets\MrTrust.ico"; ResourceName = "MrTrust.Payload.assets.MrTrust.ico" }, + @{ Path = "assets\certificates\MrSphay-LocalTrust-Root.cer"; ResourceName = "MrTrust.Payload.assets.certificates.MrSphay-LocalTrust-Root.cer" }, + @{ Path = "assets\certificates\MrSphay-CodeSigning.cer"; ResourceName = "MrTrust.Payload.assets.certificates.MrSphay-CodeSigning.cer" }, + @{ Path = "assets\certificates\thumbprints.txt"; ResourceName = "MrTrust.Payload.assets.certificates.thumbprints.txt" } +) if (-not (Test-Path -LiteralPath $sourcePath)) { throw "Launcher source not found: $sourcePath" @@ -25,6 +40,13 @@ if (-not (Test-Path -LiteralPath $iconPath)) { & (Join-Path $root "scripts\New-MrTrustIcon.ps1") -OutputPath $iconPath } +foreach ($payloadFile in $payloadFiles) { + $payloadPath = Join-Path $root $payloadFile.Path + if (-not (Test-Path -LiteralPath $payloadPath)) { + throw "Payload file not found: $payloadPath" + } +} + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null $compilerCandidates = @( @@ -37,20 +59,29 @@ if (-not $compiler) { throw "csc.exe was not found. Run this build on a Windows Gitea runner with .NET Framework installed." } -& $compiler ` - /nologo ` - /target:winexe ` - /optimize+ ` - /platform:anycpu ` - /out:$resolvedOutputPath ` - /win32icon:$iconPath ` - /reference:System.Windows.Forms.dll ` - /reference:System.Drawing.dll ` - $sourcePath +$compilerArguments = @( + "/nologo", + "/target:winexe", + "/optimize+", + "/platform:anycpu", + "/out:$resolvedOutputPath", + "/win32icon:$iconPath", + "/reference:System.Windows.Forms.dll", + "/reference:System.Drawing.dll" +) + +foreach ($payloadFile in $payloadFiles) { + $payloadPath = Join-Path $root $payloadFile.Path + $compilerArguments += "/resource:$payloadPath,$($payloadFile.ResourceName)" +} + +$compilerArguments += $sourcePath + +& $compiler @compilerArguments if ($LASTEXITCODE -ne 0) { throw "csc.exe failed with exit code $LASTEXITCODE." } -Write-Host "Created EXE:" +Write-Host "Created standalone EXE:" Write-Host " $resolvedOutputPath" diff --git a/scripts/New-MrTrustRelease.ps1 b/scripts/New-MrTrustRelease.ps1 index 6ee3a3a..9c7d41d 100644 --- a/scripts/New-MrTrustRelease.ps1 +++ b/scripts/New-MrTrustRelease.ps1 @@ -27,9 +27,6 @@ if (Test-Path -LiteralPath $packageRoot) { } New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null -New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot "scripts") | Out-Null -New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot "assets\certificates") | Out-Null -New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot "docs") | Out-Null if (-not (Test-Path -LiteralPath $iconPath)) { & (Join-Path $root "scripts\New-MrTrustIcon.ps1") -OutputPath $iconPath @@ -55,16 +52,7 @@ if ($SigningThumbprint) { } Copy-Item -LiteralPath $exePath -Destination $packageRoot -Copy-Item -LiteralPath (Join-Path $root "MrTrust.ps1") -Destination $packageRoot Copy-Item -LiteralPath (Join-Path $root "README.md") -Destination $packageRoot -Copy-Item -LiteralPath $iconPath -Destination (Join-Path $packageRoot "assets") -Copy-Item -LiteralPath (Join-Path $root "scripts\Install-MrTrust.ps1") -Destination (Join-Path $packageRoot "scripts") -Copy-Item -LiteralPath (Join-Path $root "scripts\Uninstall-MrTrust.ps1") -Destination (Join-Path $packageRoot "scripts") -Copy-Item -LiteralPath (Join-Path $root "scripts\Start-MrTrustGui.ps1") -Destination (Join-Path $packageRoot "scripts") -Copy-Item -LiteralPath (Join-Path $root "assets\certificates\MrSphay-LocalTrust-Root.cer") -Destination (Join-Path $packageRoot "assets\certificates") -Copy-Item -LiteralPath (Join-Path $root "assets\certificates\MrSphay-CodeSigning.cer") -Destination (Join-Path $packageRoot "assets\certificates") -Copy-Item -LiteralPath (Join-Path $root "assets\certificates\thumbprints.txt") -Destination (Join-Path $packageRoot "assets\certificates") -Copy-Item -LiteralPath (Join-Path $root "docs\security-model.md") -Destination (Join-Path $packageRoot "docs") if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force diff --git a/src/MrTrustLauncher.cs b/src/MrTrustLauncher.cs index f8ad4d4..f325fc2 100644 --- a/src/MrTrustLauncher.cs +++ b/src/MrTrustLauncher.cs @@ -1,34 +1,51 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; using System.Windows.Forms; +#pragma warning disable CS8600, CS8602 + namespace MrTrust { internal static class MrTrustLauncher { - [STAThread] - private static int Main() - { - string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; - string scriptPath = Path.Combine(baseDirectory, "MrTrust.ps1"); + private const string PayloadResourcePrefix = "MrTrust.Payload."; - if (!File.Exists(scriptPath)) - { - MessageBox.Show( - "MrTrust.ps1 was not found next to MrTrust.exe.", - "MrTrust", - MessageBoxButtons.OK, - MessageBoxIcon.Error); - return 1; - } + private static readonly PayloadFile[] PayloadFiles = + { + new PayloadFile("MrTrust.ps1", "MrTrust.ps1"), + new PayloadFile("scripts.Build-MrTrustExe.ps1", Path.Combine("scripts", "Build-MrTrustExe.ps1")), + new PayloadFile("scripts.Install-MrTrust.ps1", Path.Combine("scripts", "Install-MrTrust.ps1")), + new PayloadFile("scripts.New-MrTrustCertificate.ps1", Path.Combine("scripts", "New-MrTrustCertificate.ps1")), + new PayloadFile("scripts.New-MrTrustIcon.ps1", Path.Combine("scripts", "New-MrTrustIcon.ps1")), + new PayloadFile("scripts.New-MrTrustRelease.ps1", Path.Combine("scripts", "New-MrTrustRelease.ps1")), + new PayloadFile("scripts.Sign-MrTrustProject.ps1", Path.Combine("scripts", "Sign-MrTrustProject.ps1")), + new PayloadFile("scripts.Start-MrTrustGui.ps1", Path.Combine("scripts", "Start-MrTrustGui.ps1")), + new PayloadFile("scripts.Uninstall-MrTrust.ps1", Path.Combine("scripts", "Uninstall-MrTrust.ps1")), + new PayloadFile("assets.MrTrust.ico", Path.Combine("assets", "MrTrust.ico")), + new PayloadFile("assets.certificates.MrSphay-LocalTrust-Root.cer", Path.Combine("assets", "certificates", "MrSphay-LocalTrust-Root.cer")), + new PayloadFile("assets.certificates.MrSphay-CodeSigning.cer", Path.Combine("assets", "certificates", "MrSphay-CodeSigning.cer")), + new PayloadFile("assets.certificates.thumbprints.txt", Path.Combine("assets", "certificates", "thumbprints.txt")) + }; + + [STAThread] + private static int Main(string[] args) + { + string baseDirectory = string.Empty; try { + baseDirectory = ExtractPayload(); + string scriptPath = Path.Combine(baseDirectory, "MrTrust.ps1"); + string commandArguments = BuildCommandArguments(args); + ProcessStartInfo startInfo = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = "-NoProfile -ExecutionPolicy Bypass -File \"" + scriptPath + "\" gui", + Arguments = "-NoProfile -ExecutionPolicy Bypass -File " + QuoteArgument(scriptPath) + " " + commandArguments, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = baseDirectory @@ -40,9 +57,10 @@ namespace MrTrust { throw new InvalidOperationException("PowerShell could not be started."); } - } - return 0; + process.WaitForExit(); + return process.ExitCode; + } } catch (Exception ex) { @@ -53,6 +71,123 @@ namespace MrTrust MessageBoxIcon.Error); return 1; } + finally + { + TryDeleteDirectory(baseDirectory); + } + } + + private static string ExtractPayload() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + string versionKey = GetPayloadVersionKey(assembly); + string targetDirectory = Path.Combine( + Path.GetTempPath(), + "MrTrust", + "standalone", + versionKey, + Guid.NewGuid().ToString("N")); + + foreach (PayloadFile payloadFile in PayloadFiles) + { + string targetPath = Path.Combine(targetDirectory, payloadFile.RelativePath); + string targetParent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(targetParent)) + { + Directory.CreateDirectory(targetParent); + } + + using (Stream stream = assembly.GetManifestResourceStream(PayloadResourcePrefix + payloadFile.ResourceName)) + { + if (stream == null) + { + throw new FileNotFoundException("Embedded MrTrust payload file was not found.", payloadFile.RelativePath); + } + + using (FileStream file = File.Create(targetPath)) + { + stream.CopyTo(file); + } + } + } + + return targetDirectory; + } + + private static void TryDeleteDirectory(string directory) + { + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + try + { + Directory.Delete(directory, true); + } + catch + { + // Best-effort cleanup only. A locked icon or antivirus scan should not mask the command result. + } + } + + private static string GetPayloadVersionKey(Assembly assembly) + { + string location = Application.ExecutablePath; + if (File.Exists(location)) + { + FileInfo fileInfo = new FileInfo(location); + return fileInfo.Length.ToString("x") + "-" + fileInfo.LastWriteTimeUtc.Ticks.ToString("x"); + } + + return assembly.GetName().Version.ToString(); + } + + private static string BuildCommandArguments(string[] args) + { + string[] effectiveArgs = args.Length == 0 ? new[] { "gui" } : args; + return string.Join(" ", effectiveArgs.Select(QuoteArgument).ToArray()); + } + + private static string QuoteArgument(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "\"\""; + } + + if (value.IndexOfAny(new[] { ' ', '\t', '\n', '\r', '"' }) < 0) + { + return value; + } + + StringBuilder builder = new StringBuilder(); + builder.Append('"'); + foreach (char character in value) + { + if (character == '"') + { + builder.Append('\\'); + } + + builder.Append(character); + } + + builder.Append('"'); + return builder.ToString(); + } + + private sealed class PayloadFile + { + public PayloadFile(string resourceName, string relativePath) + { + ResourceName = resourceName; + RelativePath = relativePath; + } + + public string ResourceName { get; private set; } + + public string RelativePath { get; private set; } } } } diff --git a/src/MrTrustLauncher.csproj b/src/MrTrustLauncher.csproj index 14e922f..6e0d021 100644 --- a/src/MrTrustLauncher.csproj +++ b/src/MrTrustLauncher.csproj @@ -9,7 +9,23 @@ enable enable true - false + true + win-x64 ..\assets\MrTrust.ico + + + + + + + + + + + + + + +