fix(security): pin SSH trust and protect session keys (#8)

Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
This commit is contained in:
ochenstarik-ui 2026-07-31 02:36:52 +07:00 committed by GitHub
parent 93e0f8ddbd
commit c13501e529
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 685 additions and 32 deletions

View file

@ -33,6 +33,9 @@ jobs:
shell: pwsh shell: pwsh
run: ./tests/windows/Test-DesktopContracts.ps1 run: ./tests/windows/Test-DesktopContracts.ps1
- name: Test Desktop security
run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release
- name: Build test-signed MSIX installer - name: Build test-signed MSIX installer
shell: pwsh shell: pwsh
run: | run: |

View file

@ -2,6 +2,7 @@
using Windows.ApplicationModel.Activation; using Windows.ApplicationModel.Activation;
using Windows.Foundation; using Windows.Foundation;
using Windows.Foundation.Collections; using Windows.Foundation.Collections;
using Windows.Storage;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Controls.Primitives;
@ -30,6 +31,7 @@ public partial class App : Application
public App() public App()
{ {
InitializeComponent(); InitializeComponent();
SshPrivateKeySession.CleanupOrphans(ApplicationData.Current.TemporaryFolder.Path);
} }
/// <summary> /// <summary>

View file

@ -424,6 +424,63 @@ public sealed partial class MainPage : Page
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task<string?> ConfirmHostKeyAsync(ServerProfileData profile)
{
try
{
SshHostKeyCandidate candidate;
using (var scanTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)))
{
candidate = await _ssh.ScanHostKeyAsync(profile, scanTimeout.Token);
}
var fingerprintBox = new TextBox
{
Text = candidate.Fingerprint,
IsReadOnly = true,
TextWrapping = TextWrapping.Wrap
};
AutomationProperties.SetName(fingerprintBox, "Fingerprint SSH host key");
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Подтвердите SSH host key: {profile.Name}",
Content = new StackPanel
{
Spacing = 12,
MinWidth = 480,
Children =
{
new TextBlock
{
Text = $"Алгоритм: {candidate.KeyType}\nСверьте fingerprint через доверенную консоль сервера или панель провайдера. Не подтверждайте его только по данным текущего подключения.",
TextWrapping = TextWrapping.Wrap
},
fingerprintBox
}
},
PrimaryButtonText = "Fingerprint совпадает",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return null;
}
using var persistTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _ssh.TrustHostKeyAsync(candidate, persistTimeout.Token);
return candidate.Fingerprint;
}
catch (Exception exception)
{
ShowInfo(
"Не удалось проверить SSH host key",
exception.Message,
InfoBarSeverity.Error);
return null;
}
}
private async void AddServerButton_Click(object sender, RoutedEventArgs e) private async void AddServerButton_Click(object sender, RoutedEventArgs e)
{ {
try try
@ -497,6 +554,12 @@ public sealed partial class MainPage : Page
checked((int)portBox.Value), checked((int)portBox.Value),
userBox.Text.Trim(), userBox.Text.Trim(),
hubBox.IsChecked == true); hubBox.IsChecked == true);
var hostKeyFingerprint = await ConfirmHostKeyAsync(profile);
if (hostKeyFingerprint is null)
{
return;
}
profile = profile with { HostKeyFingerprint = hostKeyFingerprint };
var server = new ServerViewModel(profile); var server = new ServerViewModel(profile);
Servers.Add(server); Servers.Add(server);
await SaveProfilesAsync(); await SaveProfilesAsync();
@ -558,13 +621,20 @@ public sealed partial class MainPage : Page
} }
var index = Servers.IndexOf(selected); var index = Servers.IndexOf(selected);
var updated = new ServerViewModel(new ServerProfileData( var updatedProfile = new ServerProfileData(
selected.Profile.Id, selected.Profile.Id,
nameBox.Text.Trim(), nameBox.Text.Trim(),
hostBox.Text.Trim(), hostBox.Text.Trim(),
checked((int)portBox.Value), checked((int)portBox.Value),
userBox.Text.Trim(), userBox.Text.Trim(),
hubBox.IsChecked == true)); hubBox.IsChecked == true);
var hostKeyFingerprint = await ConfirmHostKeyAsync(updatedProfile);
if (hostKeyFingerprint is null)
{
return;
}
var updated = new ServerViewModel(
updatedProfile with { HostKeyFingerprint = hostKeyFingerprint });
Servers[index] = updated; Servers[index] = updated;
await SaveProfilesAsync(); await SaveProfilesAsync();
await RefreshServerAsync(updated); await RefreshServerAsync(updated);

View file

@ -9,7 +9,8 @@ public sealed record ServerProfileData(
string Host, string Host,
int Port, int Port,
string User, string User,
bool IsHub = false); bool IsHub = false,
string? HostKeyFingerprint = null);
public sealed class ServerViewModel : INotifyPropertyChanged public sealed class ServerViewModel : INotifyPropertyChanged
{ {

View file

@ -0,0 +1,176 @@
using System.Security.Cryptography;
using System.Text;
namespace ServerMonitorManager_Desktop;
internal sealed record SshHostKeyCandidate(
string Host,
int Port,
string KeyType,
string KeyData,
string Fingerprint,
string KnownHostsLine);
internal static class SshHostKeyTrust
{
private static readonly string[] PreferredKeyTypes =
[
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa"
];
private static readonly SemaphoreSlim WriteLock = new(1, 1);
internal static string GetPinPath(string directory, string host, int port)
{
var endpoint = Encoding.UTF8.GetBytes(FormatEndpoint(host, port));
try
{
var fileName = $"{Convert.ToHexString(SHA256.HashData(endpoint))}.known_hosts";
return Path.Combine(directory, fileName);
}
finally
{
CryptographicOperations.ZeroMemory(endpoint);
}
}
internal static SshHostKeyCandidate ParseCandidate(string host, int port, string keyScanOutput)
{
var endpoint = FormatEndpoint(host, port);
var candidates = keyScanOutput
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !line.StartsWith('#'))
.Select(SplitFields)
.Where(parts => parts.Length >= 3 && string.Equals(parts[0], endpoint, StringComparison.Ordinal))
.ToArray();
foreach (var keyType in PreferredKeyTypes)
{
var parts = candidates.FirstOrDefault(parts => string.Equals(
parts[1],
keyType,
StringComparison.Ordinal));
if (parts is null)
{
continue;
}
byte[] keyBlob;
try
{
keyBlob = Convert.FromBase64String(parts[2]);
}
catch (FormatException exception)
{
throw new InvalidOperationException("SSH host key contains invalid base64 data.", exception);
}
try
{
var fingerprint = Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=');
return new SshHostKeyCandidate(
host,
port,
keyType,
parts[2],
$"SHA256:{fingerprint}",
$"{endpoint} {keyType} {parts[2]}");
}
finally
{
CryptographicOperations.ZeroMemory(keyBlob);
}
}
throw new InvalidOperationException(
$"SSH key scan returned no supported key for the expected endpoint {endpoint}.");
}
internal static async Task WriteAsync(
string path,
SshHostKeyCandidate candidate,
CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(path)
?? throw new InvalidOperationException("known_hosts path has no parent directory.");
Directory.CreateDirectory(directory);
var temporaryPath = Path.Combine(directory, $".known_hosts-{Guid.NewGuid():N}.tmp");
await WriteLock.WaitAsync(cancellationToken);
try
{
await File.WriteAllLinesAsync(
temporaryPath,
[candidate.KnownHostsLine],
cancellationToken);
File.Move(temporaryPath, path, overwrite: true);
}
finally
{
try
{
File.Delete(temporaryPath);
}
finally
{
WriteLock.Release();
}
}
}
internal static bool IsTrusted(
string path,
string host,
int port,
string? expectedFingerprint)
{
if (string.IsNullOrWhiteSpace(expectedFingerprint) || !File.Exists(path))
{
return false;
}
var endpoint = FormatEndpoint(host, port);
foreach (var line in File.ReadLines(path))
{
var parts = SplitFields(line);
if (parts.Length < 3
|| !string.Equals(parts[0], endpoint, StringComparison.Ordinal)
|| !PreferredKeyTypes.Contains(parts[1], StringComparer.Ordinal))
{
continue;
}
try
{
var keyBlob = Convert.FromBase64String(parts[2]);
try
{
var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
if (string.Equals(actual, expectedFingerprint, StringComparison.Ordinal))
{
return true;
}
}
finally
{
CryptographicOperations.ZeroMemory(keyBlob);
}
}
catch (FormatException)
{
return false;
}
}
return false;
}
private static string[] SplitFields(string line)
=> line.Split(
[' ', '\t'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static string FormatEndpoint(string host, int port)
=> port == 22 ? host : $"[{host}]:{port}";
}

View file

@ -117,34 +117,78 @@ public sealed partial class SshMonitorService
throw new InvalidOperationException("Некорректная команда управления Mesh."); throw new InvalidOperationException("Некорректная команда управления Mesh.");
} }
await EnsureKeyPairAsync(cancellationToken);
var localFolder = ApplicationData.Current.LocalFolder.Path; var localFolder = ApplicationData.Current.LocalFolder.Path;
var privateKeyPath = await MaterializePrivateKeyAsync(cancellationToken); var knownHostsPath = SshHostKeyTrust.GetPinPath(
var knownHostsPath = Path.Combine(localFolder, "ssh", "known_hosts"); Path.Combine(localFolder, "ssh", "known-hosts"),
profile.Host,
profile.Port);
if (!SshHostKeyTrust.IsTrusted(
knownHostsPath,
profile.Host,
profile.Port,
profile.HostKeyFingerprint))
{
throw new InvalidOperationException(
"SSH host key is not explicitly confirmed for this server profile.");
}
await EnsureKeyPairAsync(cancellationToken);
await using var privateKeySession = await MaterializePrivateKeyAsync(cancellationToken);
var target = $"{profile.User}@{profile.Host}"; var target = $"{profile.User}@{profile.Host}";
var arguments = new[] var arguments = new[]
{ {
"-i", privateKeyPath, "-F", "none",
"-i", privateKeySession.Path,
"-p", profile.Port.ToString(CultureInfo.InvariantCulture), "-p", profile.Port.ToString(CultureInfo.InvariantCulture),
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
"-o", "ConnectTimeout=8", "-o", "ConnectTimeout=8",
"-o", "IdentitiesOnly=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=accept-new", "-o", "IdentityAgent=none",
"-o", "StrictHostKeyChecking=yes",
"-o", $"UserKnownHostsFile={knownHostsPath}", "-o", $"UserKnownHostsFile={knownHostsPath}",
"-o", "GlobalKnownHostsFile=none",
"-o", "KnownHostsCommand=none",
"-o", "UpdateHostKeys=no",
"-o", "VerifyHostKeyDNS=no",
"-o", "CanonicalizeHostname=no",
"-o", "CheckHostIP=no",
target, target,
command command
}; };
try return await RunProcessAsync(
{ ResolveOpenSshTool("ssh.exe"),
return await RunProcessAsync( arguments,
ResolveOpenSshTool("ssh.exe"), cancellationToken);
arguments, }
cancellationToken);
} internal async Task<SshHostKeyCandidate> ScanHostKeyAsync(
finally ServerProfileData profile,
{ CancellationToken cancellationToken = default)
File.Delete(privateKeyPath); {
} ValidateProfile(profile);
var output = await RunProcessAsync(
ResolveOpenSshTool("ssh-keyscan.exe"),
[
"-T", "8",
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
profile.Host
],
cancellationToken);
return SshHostKeyTrust.ParseCandidate(profile.Host, profile.Port, output);
}
internal async Task TrustHostKeyAsync(
SshHostKeyCandidate candidate,
CancellationToken cancellationToken = default)
{
var knownHostsPath = SshHostKeyTrust.GetPinPath(
Path.Combine(
ApplicationData.Current.LocalFolder.Path,
"ssh",
"known-hosts"),
candidate.Host,
candidate.Port);
await SshHostKeyTrust.WriteAsync(knownHostsPath, candidate, cancellationToken);
} }
public void OpenInteractiveTerminal(ServerProfileData profile, string terminalUser) public void OpenInteractiveTerminal(ServerProfileData profile, string terminalUser)
@ -184,28 +228,31 @@ public sealed partial class SshMonitorService
?? throw new InvalidOperationException("Не удалось открыть SSH-терминал."); ?? throw new InvalidOperationException("Не удалось открыть SSH-терминал.");
} }
private static async Task<string> MaterializePrivateKeyAsync(CancellationToken cancellationToken) private static async Task<SshPrivateKeySession> MaterializePrivateKeyAsync(
CancellationToken cancellationToken)
{ {
var localFolder = ApplicationData.Current.LocalFolder.Path; var localFolder = ApplicationData.Current.LocalFolder.Path;
var protectedKeyPath = Path.Combine(localFolder, "ssh", KeyFileName + ProtectedKeySuffix); var protectedKeyPath = Path.Combine(localFolder, "ssh", KeyFileName + ProtectedKeySuffix);
var protectedKey = await File.ReadAllBytesAsync(protectedKeyPath, cancellationToken); var protectedKey = await File.ReadAllBytesAsync(protectedKeyPath, cancellationToken);
var privateKey = ProtectedData.Unprotect(protectedKey, null, DataProtectionScope.CurrentUser); byte[]? privateKey = null;
var temporaryFile = await ApplicationData.Current.TemporaryFolder.CreateFileAsync(
$"{KeyFileName}-{Guid.NewGuid():N}",
CreationCollisionOption.FailIfExists);
try try
{ {
await File.WriteAllBytesAsync(temporaryFile.Path, privateKey, cancellationToken); privateKey = ProtectedData.Unprotect(
return temporaryFile.Path; protectedKey,
} optionalEntropy: null,
catch DataProtectionScope.CurrentUser);
{ return await SshPrivateKeySession.CreateAsync(
File.Delete(temporaryFile.Path); ApplicationData.Current.TemporaryFolder.Path,
throw; privateKey,
cancellationToken);
} }
finally finally
{ {
CryptographicOperations.ZeroMemory(privateKey); if (privateKey is not null)
{
CryptographicOperations.ZeroMemory(privateKey);
}
CryptographicOperations.ZeroMemory(protectedKey);
} }
} }

View file

@ -0,0 +1,91 @@
using System.Security.AccessControl;
using System.Security.Principal;
namespace ServerMonitorManager_Desktop;
internal sealed class SshPrivateKeySession : IAsyncDisposable
{
internal const string FilePrefix = "server-monitor-manager-ed25519-session-";
private string? _path;
private SshPrivateKeySession(string path)
{
_path = path;
}
internal string Path
=> _path ?? throw new ObjectDisposedException(nameof(SshPrivateKeySession));
internal static async Task<SshPrivateKeySession> CreateAsync(
string directory,
ReadOnlyMemory<byte> privateKey,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(directory);
var currentUser = WindowsIdentity.GetCurrent().User
?? throw new InvalidOperationException("Current Windows identity has no SID.");
var security = new FileSecurity();
security.SetOwner(currentUser);
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(
currentUser,
FileSystemRights.FullControl,
AccessControlType.Allow));
var path = System.IO.Path.Combine(directory, $"{FilePrefix}{Guid.NewGuid():N}");
try
{
await using var stream = FileSystemAclExtensions.Create(
new FileInfo(path),
FileMode.CreateNew,
FileSystemRights.FullControl,
FileShare.None,
bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.WriteThrough,
security);
await stream.WriteAsync(privateKey, cancellationToken);
await stream.FlushAsync(cancellationToken);
return new SshPrivateKeySession(path);
}
catch
{
File.Delete(path);
throw;
}
}
internal static void CleanupOrphans(string directory)
{
if (!Directory.Exists(directory))
{
return;
}
foreach (var path in Directory.EnumerateFiles(directory, $"{FilePrefix}*"))
{
try
{
File.Delete(path);
}
catch (IOException)
{
// An active SSH process can still hold a current session file.
}
catch (UnauthorizedAccessException)
{
// Leave files not owned by the current identity untouched.
}
}
}
public ValueTask DisposeAsync()
{
var path = Interlocked.Exchange(ref _path, null);
if (path is not null)
{
File.Delete(path);
}
return ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshPrivateKeySession.cs" Link="SshPrivateKeySession.cs" />
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshHostKeyTrust.cs" Link="SshHostKeyTrust.cs" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,88 @@
using ServerMonitorManager_Desktop;
using Xunit;
namespace ServerMonitorManager.Desktop.Security.Tests;
public sealed class SshHostKeyTrustTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
$"smm-host-key-tests-{Guid.NewGuid():N}");
[Fact]
public void ParseCandidatePrefersEd25519AndComputesSha256Fingerprint()
{
var candidate = SshHostKeyTrust.ParseCandidate(
"server.example",
22,
"server.example ssh-rsa AQIDBA==\nserver.example ssh-ed25519 AQIDBA==\n");
Assert.Equal("ssh-ed25519", candidate.KeyType);
Assert.Equal("SHA256:n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo", candidate.Fingerprint);
Assert.Equal("server.example ssh-ed25519 AQIDBA==", candidate.KnownHostsLine);
}
[Fact]
public void ParseCandidateRejectsUnexpectedEndpoint()
{
var exception = Assert.Throws<InvalidOperationException>(() =>
SshHostKeyTrust.ParseCandidate(
"server.example",
2222,
"[other.example]:2222 ssh-ed25519 AQIDBA==\n"));
Assert.Contains("endpoint", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WriteAsyncReplacesLegacyPatternsWithOneExclusivePin()
{
Directory.CreateDirectory(_directory);
var path = Path.Combine(_directory, "known_hosts");
await File.WriteAllTextAsync(
path,
"|1|legacy-salt|legacy-hash ssh-rsa BQYHCA==\n@cert-authority *.example ssh-ed25519 BQYHCA==\n[server.example]:2222\tssh-rsa BQYHCA==\n",
TestContext.Current.CancellationToken);
var candidate = SshHostKeyTrust.ParseCandidate(
"server.example",
2222,
"[server.example]:2222 ssh-ed25519 AQIDBA==\n");
await SshHostKeyTrust.WriteAsync(
path,
candidate,
TestContext.Current.CancellationToken);
var lines = await File.ReadAllLinesAsync(path, TestContext.Current.CancellationToken);
Assert.Equal([candidate.KnownHostsLine], lines);
Assert.True(SshHostKeyTrust.IsTrusted(
path,
"server.example",
2222,
candidate.Fingerprint));
Assert.False(SshHostKeyTrust.IsTrusted(
path,
"server.example",
2222,
"SHA256:wrong"));
}
[Fact]
public void GetPinPathIsEndpointScopedAndDoesNotExposeHostname()
{
var first = SshHostKeyTrust.GetPinPath(_directory, "server.example", 22);
var second = SshHostKeyTrust.GetPinPath(_directory, "server.example", 2222);
Assert.NotEqual(first, second);
Assert.Equal(_directory, Path.GetDirectoryName(first));
Assert.DoesNotContain("server.example", Path.GetFileName(first), StringComparison.OrdinalIgnoreCase);
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
}

View file

@ -0,0 +1,71 @@
using System.Security.AccessControl;
using System.Security.Principal;
using ServerMonitorManager_Desktop;
using Xunit;
namespace ServerMonitorManager.Desktop.Security.Tests;
public sealed class SshPrivateKeySessionTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
$"smm-desktop-security-tests-{Guid.NewGuid():N}");
[Fact]
public async Task CreateAsyncWritesOwnerOnlyKeyAndDisposeDeletesIt()
{
Directory.CreateDirectory(_directory);
var key = "test-private-key"u8.ToArray();
string path;
await using (var session = await SshPrivateKeySession.CreateAsync(
_directory,
key,
TestContext.Current.CancellationToken))
{
path = session.Path;
Assert.Equal(key, await File.ReadAllBytesAsync(
path,
TestContext.Current.CancellationToken));
var currentUser = WindowsIdentity.GetCurrent().User
?? throw new InvalidOperationException("Current Windows identity has no SID.");
var security = new FileInfo(path).GetAccessControl();
Assert.True(security.AreAccessRulesProtected);
var rules = security
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>()
.ToArray();
var rule = Assert.Single(rules);
Assert.Equal(currentUser, rule.IdentityReference);
Assert.Equal(AccessControlType.Allow, rule.AccessControlType);
Assert.Equal(FileSystemRights.FullControl, rule.FileSystemRights & FileSystemRights.FullControl);
}
Assert.False(File.Exists(path));
}
[Fact]
public void CleanupOrphansDeletesOnlyManagedKeyFiles()
{
Directory.CreateDirectory(_directory);
var orphan = Path.Combine(_directory, $"{SshPrivateKeySession.FilePrefix}{Guid.NewGuid():N}");
var unrelated = Path.Combine(_directory, "unrelated-file");
File.WriteAllText(orphan, "secret");
File.WriteAllText(unrelated, "keep");
SshPrivateKeySession.CleanupOrphans(_directory);
SshPrivateKeySession.CleanupOrphans(_directory);
Assert.False(File.Exists(orphan));
Assert.True(File.Exists(unrelated));
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
}

View file

@ -19,6 +19,14 @@ $linksCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\Pages\LinksPage.xaml.cs') Join-Path $root 'src\ServerMonitorManager.Desktop\Pages\LinksPage.xaml.cs')
$mainCode = Get-Content -Raw -Encoding UTF8 -LiteralPath ( $mainCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\MainPage.xaml.cs') Join-Path $root 'src\ServerMonitorManager.Desktop\MainPage.xaml.cs')
$appCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\App.xaml.cs')
$sshCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\SshMonitorService.cs')
$serverViewModelCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\ServerViewModel.cs')
$windowsWorkflow = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root '.github\workflows\windows-build.yml')
$requiredXamlContracts = @( $requiredXamlContracts = @(
'x:Name="LinksList"', 'x:Name="LinksList"',
@ -39,5 +47,86 @@ if ($mainCode.IndexOf(
'MeshLinksList.SelectedItem = selectedLink;', [StringComparison]::Ordinal) -lt 0) { 'MeshLinksList.SelectedItem = selectedLink;', [StringComparison]::Ordinal) -lt 0) {
throw 'Main page must synchronize the selected Link before disconnecting it.' throw 'Main page must synchronize the selected Link before disconnecting it.'
} }
if ($sshCode.IndexOf(
'await using var privateKeySession = await MaterializePrivateKeyAsync(',
[StringComparison]::Ordinal) -lt 0) {
throw 'SSH private key materialization must be scoped to an async-disposable session.'
}
if ($sshCode.IndexOf('CreateFileAsync(', [StringComparison]::Ordinal) -ge 0) {
throw 'SSH private key materialization must not use the legacy unprotected temporary file path.'
}
if ($appCode.IndexOf(
'SshPrivateKeySession.CleanupOrphans(ApplicationData.Current.TemporaryFolder.Path);',
[StringComparison]::Ordinal) -lt 0) {
throw 'Desktop startup must clean orphaned SSH key session files.'
}
if ($windowsWorkflow.IndexOf(
'tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj',
[StringComparison]::Ordinal) -lt 0) {
throw 'Windows CI must execute the Desktop security tests.'
}
if ($sshCode.IndexOf('StrictHostKeyChecking=yes', [StringComparison]::Ordinal) -lt 0 -or
$sshCode.IndexOf('StrictHostKeyChecking=accept-new', [StringComparison]::Ordinal) -ge 0) {
throw 'Restricted SSH must use only explicitly pinned host keys.'
}
$isolatedSshOptions = @(
'"-F", "none"',
'"GlobalKnownHostsFile=none"',
'"KnownHostsCommand=none"',
'"UpdateHostKeys=no"',
'"VerifyHostKeyDNS=no"',
'"CanonicalizeHostname=no"',
'"CheckHostIP=no"'
)
foreach ($option in $isolatedSshOptions) {
if ($sshCode.IndexOf($option, [StringComparison]::Ordinal) -lt 0) {
throw "Restricted SSH is missing trust-isolation option: $option"
}
}
$ssh = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
if (-not (Test-Path -LiteralPath $ssh)) {
throw "Windows OpenSSH client is missing: $ssh"
}
$effectiveSsh = (& $ssh -G -F none `
-o 'IdentitiesOnly=yes' `
-o 'IdentityAgent=none' `
-o 'StrictHostKeyChecking=yes' `
-o 'UserKnownHostsFile=C:/Temp/app-exclusive-pin.known_hosts' `
-o 'GlobalKnownHostsFile=none' `
-o 'KnownHostsCommand=none' `
-o 'UpdateHostKeys=no' `
-o 'VerifyHostKeyDNS=no' `
-o 'CanonicalizeHostname=no' `
-o 'CheckHostIP=no' `
example.invalid 2>&1) -join "`n"
if ($LASTEXITCODE -ne 0) {
throw "Windows OpenSSH rejected restricted trust options: $effectiveSsh"
}
$requiredEffectiveSsh = @(
'canonicalizehostname false',
'checkhostip no',
'identitiesonly yes',
'stricthostkeychecking true',
'verifyhostkeydns false',
'updatehostkeys false',
'identityagent none',
'globalknownhostsfile none',
'userknownhostsfile C:/Temp/app-exclusive-pin.known_hosts'
)
foreach ($option in $requiredEffectiveSsh) {
if ($effectiveSsh.IndexOf($option, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
throw "Windows OpenSSH effective config is missing: $option"
}
}
if ($effectiveSsh -match '(?m)^(hostkeyalias|knownhostscommand)\s+') {
throw 'Windows OpenSSH effective config retained an alternate host-key trust source.'
}
if ($serverViewModelCode.IndexOf(
'string? HostKeyFingerprint = null', [StringComparison]::Ordinal) -lt 0) {
throw 'Server profiles must persist the explicitly confirmed host-key fingerprint.'
}
if ($mainCode.IndexOf('ConfirmHostKeyAsync(', [StringComparison]::Ordinal) -lt 0) {
throw 'Add/edit flow must require explicit host-key fingerprint confirmation.'
}
Write-Host 'Windows desktop contracts passed.' Write-Host 'Windows desktop contracts passed.'