Prepare ms-private 0.0.1 release candidate
This commit is contained in:
1 parent
7aacc0b63f
commit
a515538438
75 files changed
+3957
-3484
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);bin/**;obj/**</DefaultItemExcludes>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\MsPrivate.Launcher\PayloadInstaller.cs" Link="PayloadInstaller.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using MsPrivate.Launcher;
|
||||
|
||||
var passed = 0;
|
||||
void Assert(bool condition, string message) { if (!condition) throw new Exception(message); passed++; }
|
||||
byte[] CreateArchive(string path, byte[] content, string? declaredPath = null, string? declaredHash = null, bool extra = false)
|
||||
{
|
||||
var manifest = new PayloadManifest
|
||||
{
|
||||
Schema = 1,
|
||||
Version = "test",
|
||||
EntryPoint = declaredPath ?? path,
|
||||
Files = [new PayloadFile { Path = declaredPath ?? path, Length = content.Length, Sha256 = declaredHash ?? Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant() }]
|
||||
};
|
||||
using var memory = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var file = archive.CreateEntry(path);
|
||||
using (var output = file.Open()) output.Write(content);
|
||||
var metadata = archive.CreateEntry(PayloadInstaller.ManifestName);
|
||||
using (var output = metadata.Open()) JsonSerializer.Serialize(output, manifest);
|
||||
if (extra) archive.CreateEntry("undeclared.txt");
|
||||
}
|
||||
return memory.ToArray();
|
||||
}
|
||||
|
||||
var root = Path.Combine(Path.GetTempPath(), "ms-private-launcher-tests-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var content = "Write-Output test"u8.ToArray();
|
||||
var valid = CreateArchive("ms-private-GUI.ps1", content);
|
||||
var manifest = PayloadInstaller.ReadAndValidateArchive(valid);
|
||||
Assert(manifest.Files.Count == 1, "Valid embedded archive was rejected.");
|
||||
var install = Path.Combine(root, "runtime");
|
||||
PayloadInstaller.EnsureInstalled(valid, install);
|
||||
Assert(PayloadInstaller.IsValidInstallation(install, manifest), "Installed payload did not verify.");
|
||||
File.WriteAllText(Path.Combine(install, "ms-private-GUI.ps1"), "tampered");
|
||||
PayloadInstaller.EnsureInstalled(valid, install);
|
||||
Assert(PayloadInstaller.IsValidInstallation(install, manifest), "Tampered installed payload was not repaired.");
|
||||
File.WriteAllText(Path.Combine(install, "unexpected.ps1"), "not declared");
|
||||
Assert(!PayloadInstaller.IsValidInstallation(install, manifest), "Unexpected installed file was accepted.");
|
||||
PayloadInstaller.EnsureInstalled(valid, install);
|
||||
Assert(PayloadInstaller.IsValidInstallation(install, manifest), "Installation with an unexpected file was not repaired.");
|
||||
File.WriteAllText(Path.Combine(install, PayloadInstaller.ManifestName), "{}");
|
||||
Assert(!PayloadInstaller.IsValidInstallation(install, manifest), "Modified installed manifest was accepted.");
|
||||
PayloadInstaller.EnsureInstalled(valid, install);
|
||||
Assert(PayloadInstaller.IsValidInstallation(install, manifest), "Modified installed manifest was not repaired.");
|
||||
try { PayloadInstaller.ReadAndValidateArchive(CreateArchive("safe.ps1", content, "../escape.ps1")); throw new Exception("Traversal manifest was accepted."); } catch (InvalidDataException) { passed++; }
|
||||
try { PayloadInstaller.ReadAndValidateArchive(CreateArchive("ms-private-GUI.ps1", content, declaredHash: new string('0', 64))); throw new Exception("Bad hash was accepted."); } catch (InvalidDataException) { passed++; }
|
||||
try { PayloadInstaller.ReadAndValidateArchive(CreateArchive("ms-private-GUI.ps1", content, extra: true)); throw new Exception("Undeclared archive entry was accepted."); } catch (InvalidDataException) { passed++; }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
Console.WriteLine($"{passed} native launcher tests passed.");
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);bin/**;obj/**</DefaultItemExcludes>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyName>ms-private</AssemblyName>
|
||||
<RootNamespace>MsPrivate.Launcher</RootNamespace>
|
||||
<Version>0.0.1</Version>
|
||||
<AssemblyVersion>0.0.1.0</AssemblyVersion>
|
||||
<FileVersion>0.0.1.0</FileVersion>
|
||||
<Product>ms-private</Product>
|
||||
<Description>Windows privacy control center</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="'$(PayloadZip)' != ''">
|
||||
<EmbeddedResource Include="$(PayloadZip)" LogicalName="MsPrivate.Payload.zip" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MsPrivate.Launcher;
|
||||
|
||||
internal sealed class PayloadManifest
|
||||
{
|
||||
public int Schema { get; set; }
|
||||
public string Version { get; set; } = "";
|
||||
public string EntryPoint { get; set; } = "";
|
||||
public List<PayloadFile> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PayloadFile
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public long Length { get; set; }
|
||||
public string Sha256 { get; set; } = "";
|
||||
}
|
||||
|
||||
internal static class PayloadInstaller
|
||||
{
|
||||
internal const string ManifestName = "payload.manifest.json";
|
||||
|
||||
internal static PayloadManifest ReadAndValidateArchive(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes, writable: false);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false);
|
||||
var manifestEntry = archive.GetEntry(ManifestName) ?? throw new InvalidDataException("Embedded payload manifest is missing.");
|
||||
PayloadManifest manifest;
|
||||
using (var stream = manifestEntry.Open())
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<PayloadManifest>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidDataException("Embedded payload manifest is invalid.");
|
||||
}
|
||||
|
||||
if (manifest.Schema != 1 || string.IsNullOrWhiteSpace(manifest.Version))
|
||||
throw new InvalidDataException("Unsupported payload manifest.");
|
||||
var entryPoint = NormalizeRelativePath(manifest.EntryPoint);
|
||||
var declared = new Dictionary<string, PayloadFile>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var relative = NormalizeRelativePath(file.Path);
|
||||
if (!declared.TryAdd(relative, file)) throw new InvalidDataException($"Duplicate payload path: {relative}");
|
||||
if (file.Length < 0 || file.Sha256.Length != 64 || file.Sha256.Any(c => !Uri.IsHexDigit(c)))
|
||||
throw new InvalidDataException($"Invalid payload metadata: {relative}");
|
||||
}
|
||||
if (!declared.ContainsKey(entryPoint)) throw new InvalidDataException("Payload entry point is not declared.");
|
||||
|
||||
var actualEntries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToArray();
|
||||
if (actualEntries.Length != declared.Count + 1) throw new InvalidDataException("Payload archive contains undeclared entries.");
|
||||
foreach (var entry in actualEntries)
|
||||
{
|
||||
var relative = NormalizeRelativePath(entry.FullName);
|
||||
if (relative.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!declared.TryGetValue(relative, out var expected)) throw new InvalidDataException($"Undeclared payload entry: {relative}");
|
||||
using var stream = entry.Open();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
if (entry.Length != expected.Length || !hash.Equals(expected.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"Embedded payload verification failed: {relative}");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
internal static void EnsureInstalled(byte[] archiveBytes, string installDirectory)
|
||||
{
|
||||
var manifest = ReadAndValidateArchive(archiveBytes);
|
||||
var destination = Path.GetFullPath(installDirectory);
|
||||
if (IsValidInstallation(destination, manifest)) return;
|
||||
|
||||
var parent = Directory.GetParent(destination)?.FullName ?? throw new InvalidDataException("Invalid runtime destination.");
|
||||
Directory.CreateDirectory(parent);
|
||||
var staging = Path.Combine(parent, ".staging-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(staging);
|
||||
try
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes, writable: false);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false);
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var relative = NormalizeRelativePath(file.Path);
|
||||
var entry = archive.GetEntry(relative) ?? throw new InvalidDataException($"Payload entry disappeared: {relative}");
|
||||
var target = ResolveBelow(staging, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
using var input = entry.Open();
|
||||
using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
input.CopyTo(output);
|
||||
output.Flush(flushToDisk: true);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(staging, ManifestName), JsonSerializer.Serialize(manifest));
|
||||
if (!IsValidInstallation(staging, manifest)) throw new InvalidDataException("Extracted payload verification failed.");
|
||||
if (Directory.Exists(destination)) DeleteOrdinaryDirectory(destination);
|
||||
Directory.Move(staging, destination);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(staging)) DeleteOrdinaryDirectory(staging);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsValidInstallation(string directory, PayloadManifest manifest)
|
||||
{
|
||||
if (!Directory.Exists(directory) || IsReparsePoint(directory)) return false;
|
||||
var expectedPaths = manifest.Files
|
||||
.Select(file => NormalizeRelativePath(file.Path))
|
||||
.Append(ManifestName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in EnumerateOrdinaryFiles(directory))
|
||||
{
|
||||
var relative = Path.GetRelativePath(directory, path).Replace('\\', '/');
|
||||
if (!expectedPaths.Remove(relative)) return false;
|
||||
}
|
||||
if (expectedPaths.Count != 0) return false;
|
||||
var installedManifest = Path.Combine(directory, ManifestName);
|
||||
if (!File.ReadAllText(installedManifest).Equals(JsonSerializer.Serialize(manifest), StringComparison.Ordinal)) return false;
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var path = ResolveBelow(directory, NormalizeRelativePath(file.Path));
|
||||
if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) return false;
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length != file.Length) return false;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
if (!hash.Equals(file.Sha256, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static string NormalizeRelativePath(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || Path.IsPathRooted(value)) throw new InvalidDataException("Payload path must be relative.");
|
||||
var normalized = value.Replace('\\', '/').Trim('/');
|
||||
if (normalized.Length == 0 || normalized.Contains(':')) throw new InvalidDataException("Invalid payload path.");
|
||||
var segments = normalized.Split('/');
|
||||
if (segments.Any(s => s is "" or "." or "..")) throw new InvalidDataException("Payload path traversal was refused.");
|
||||
return string.Join('/', segments);
|
||||
}
|
||||
|
||||
internal static string ResolveBelow(string root, string relative)
|
||||
{
|
||||
var basePath = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var target = Path.GetFullPath(Path.Combine(basePath, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!target.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload escaped its runtime directory.");
|
||||
return target;
|
||||
}
|
||||
|
||||
internal static bool IsReparsePoint(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
|
||||
|
||||
internal static void DeleteOrdinaryDirectory(string path)
|
||||
{
|
||||
_ = EnumerateOrdinaryFiles(path).ToArray();
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateOrdinaryFiles(string directory)
|
||||
{
|
||||
var root = new DirectoryInfo(directory);
|
||||
if (!root.Exists) yield break;
|
||||
if ((root.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
throw new InvalidDataException("Refusing to follow a runtime reparse point.");
|
||||
foreach (var entry in root.EnumerateFileSystemInfos())
|
||||
{
|
||||
if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
throw new InvalidDataException("Refusing to follow a runtime reparse point.");
|
||||
if (entry is DirectoryInfo child)
|
||||
{
|
||||
foreach (var file in EnumerateOrdinaryFiles(child.FullName)) yield return file;
|
||||
}
|
||||
else if (entry is FileInfo file)
|
||||
{
|
||||
yield return file.FullName;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidDataException("Unsupported runtime entry type.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MsPrivate.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const string PayloadResource = "MsPrivate.Payload.zip";
|
||||
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
|
||||
Application.EnableVisualStyles();
|
||||
using var mutex = new Mutex(initiallyOwned: true, @"Local\ms-private-launcher", out var ownsMutex);
|
||||
if (!ownsMutex)
|
||||
{
|
||||
MessageBox.Show("ms-private is already running.", "ms-private", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return 2;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || !Environment.Is64BitOperatingSystem) throw new PlatformNotSupportedException("ms-private requires 64-bit Windows.");
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) throw new UnauthorizedAccessException("Administrator rights are required.");
|
||||
|
||||
var bytes = ReadPayload();
|
||||
var payloadHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version ?? throw new InvalidDataException("The launcher version is missing.");
|
||||
var productVersion = $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
var runtimeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "ms-private", "runtime");
|
||||
var runtimeName = productVersion + "-" + payloadHash[..12];
|
||||
var runtimeDirectory = Path.Combine(runtimeRoot, runtimeName);
|
||||
PayloadInstaller.EnsureInstalled(bytes, runtimeDirectory);
|
||||
RemoveOlderRuntimes(runtimeRoot, runtimeDirectory);
|
||||
|
||||
var entryPoint = Path.Combine(runtimeDirectory, "ms-private-GUI.ps1");
|
||||
var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
if (!File.Exists(powershell)) throw new FileNotFoundException("Windows PowerShell 5.1 was not found.", powershell);
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = powershell,
|
||||
WorkingDirectory = runtimeDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-ExecutionPolicy", "RemoteSigned", "-File", entryPoint }) start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Windows PowerShell could not be started.");
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException($"The ms-private interface exited with code {process.ExitCode}.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
MessageBox.Show(exception.Message, "ms-private could not start", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadPayload()
|
||||
{
|
||||
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(PayloadResource)
|
||||
?? throw new InvalidDataException("The embedded ms-private payload is missing.");
|
||||
using var memory = new MemoryStream();
|
||||
stream.CopyTo(memory);
|
||||
return memory.ToArray();
|
||||
}
|
||||
|
||||
private static void RemoveOlderRuntimes(string runtimeRoot, string currentDirectory)
|
||||
{
|
||||
if (!Directory.Exists(runtimeRoot) || PayloadInstaller.IsReparsePoint(runtimeRoot)) return;
|
||||
foreach (var directory in Directory.GetDirectories(runtimeRoot))
|
||||
{
|
||||
if (Path.GetFullPath(directory).Equals(Path.GetFullPath(currentDirectory), StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (PayloadInstaller.IsReparsePoint(directory)) continue;
|
||||
try { PayloadInstaller.DeleteOrdinaryDirectory(directory); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.0.1.0" name="ms-private" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
Reference in new issue
Block a user