From c13501e5291f533da5e8890756fe0671d9de3813 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Fri, 31 Jul 2026 02:36:52 +0700 Subject: [PATCH] fix(security): pin SSH trust and protect session keys (#8) Co-authored-by: Ochenstarik --- .github/workflows/windows-build.yml | 3 + src/ServerMonitorManager.Desktop/App.xaml.cs | 2 + .../MainPage.xaml.cs | 74 +++++++- .../ServerViewModel.cs | 3 +- .../SshHostKeyTrust.cs | 176 ++++++++++++++++++ .../SshMonitorService.cs | 105 ++++++++--- .../SshPrivateKeySession.cs | 91 +++++++++ ...nitorManager.Desktop.Security.Tests.csproj | 15 ++ .../SshHostKeyTrustTests.cs | 88 +++++++++ .../SshPrivateKeySessionTests.cs | 71 +++++++ tests/windows/Test-DesktopContracts.ps1 | 89 +++++++++ 11 files changed, 685 insertions(+), 32 deletions(-) create mode 100644 src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs create mode 100644 src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs create mode 100644 tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj create mode 100644 tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs create mode 100644 tests/ServerMonitorManager.Desktop.Security.Tests/SshPrivateKeySessionTests.cs diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2a087cc..83f117a 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -33,6 +33,9 @@ jobs: shell: pwsh 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 shell: pwsh run: | diff --git a/src/ServerMonitorManager.Desktop/App.xaml.cs b/src/ServerMonitorManager.Desktop/App.xaml.cs index a8d9e0c..060e314 100644 --- a/src/ServerMonitorManager.Desktop/App.xaml.cs +++ b/src/ServerMonitorManager.Desktop/App.xaml.cs @@ -2,6 +2,7 @@ using Windows.ApplicationModel.Activation; using Windows.Foundation; using Windows.Foundation.Collections; +using Windows.Storage; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; @@ -30,6 +31,7 @@ public partial class App : Application public App() { InitializeComponent(); + SshPrivateKeySession.CleanupOrphans(ApplicationData.Current.TemporaryFolder.Path); } /// diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs index ba0d945..a05a31f 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs @@ -424,6 +424,63 @@ public sealed partial class MainPage : Page return Task.CompletedTask; } + private async Task 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) { try @@ -497,6 +554,12 @@ public sealed partial class MainPage : Page checked((int)portBox.Value), userBox.Text.Trim(), hubBox.IsChecked == true); + var hostKeyFingerprint = await ConfirmHostKeyAsync(profile); + if (hostKeyFingerprint is null) + { + return; + } + profile = profile with { HostKeyFingerprint = hostKeyFingerprint }; var server = new ServerViewModel(profile); Servers.Add(server); await SaveProfilesAsync(); @@ -558,13 +621,20 @@ public sealed partial class MainPage : Page } var index = Servers.IndexOf(selected); - var updated = new ServerViewModel(new ServerProfileData( + var updatedProfile = new ServerProfileData( selected.Profile.Id, nameBox.Text.Trim(), hostBox.Text.Trim(), checked((int)portBox.Value), 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; await SaveProfilesAsync(); await RefreshServerAsync(updated); diff --git a/src/ServerMonitorManager.Desktop/ServerViewModel.cs b/src/ServerMonitorManager.Desktop/ServerViewModel.cs index 8053620..5759db2 100644 --- a/src/ServerMonitorManager.Desktop/ServerViewModel.cs +++ b/src/ServerMonitorManager.Desktop/ServerViewModel.cs @@ -9,7 +9,8 @@ public sealed record ServerProfileData( string Host, int Port, string User, - bool IsHub = false); + bool IsHub = false, + string? HostKeyFingerprint = null); public sealed class ServerViewModel : INotifyPropertyChanged { diff --git a/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs b/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs new file mode 100644 index 0000000..4ef318e --- /dev/null +++ b/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs @@ -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}"; +} diff --git a/src/ServerMonitorManager.Desktop/SshMonitorService.cs b/src/ServerMonitorManager.Desktop/SshMonitorService.cs index ac2a0a6..e63988c 100644 --- a/src/ServerMonitorManager.Desktop/SshMonitorService.cs +++ b/src/ServerMonitorManager.Desktop/SshMonitorService.cs @@ -117,34 +117,78 @@ public sealed partial class SshMonitorService throw new InvalidOperationException("Некорректная команда управления Mesh."); } - await EnsureKeyPairAsync(cancellationToken); var localFolder = ApplicationData.Current.LocalFolder.Path; - var privateKeyPath = await MaterializePrivateKeyAsync(cancellationToken); - var knownHostsPath = Path.Combine(localFolder, "ssh", "known_hosts"); + var knownHostsPath = SshHostKeyTrust.GetPinPath( + 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 arguments = new[] { - "-i", privateKeyPath, + "-F", "none", + "-i", privateKeySession.Path, "-p", profile.Port.ToString(CultureInfo.InvariantCulture), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "IdentitiesOnly=yes", - "-o", "StrictHostKeyChecking=accept-new", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=yes", "-o", $"UserKnownHostsFile={knownHostsPath}", + "-o", "GlobalKnownHostsFile=none", + "-o", "KnownHostsCommand=none", + "-o", "UpdateHostKeys=no", + "-o", "VerifyHostKeyDNS=no", + "-o", "CanonicalizeHostname=no", + "-o", "CheckHostIP=no", target, command }; - try - { - return await RunProcessAsync( - ResolveOpenSshTool("ssh.exe"), - arguments, - cancellationToken); - } - finally - { - File.Delete(privateKeyPath); - } + return await RunProcessAsync( + ResolveOpenSshTool("ssh.exe"), + arguments, + cancellationToken); + } + + internal async Task ScanHostKeyAsync( + ServerProfileData profile, + CancellationToken cancellationToken = default) + { + 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) @@ -184,28 +228,31 @@ public sealed partial class SshMonitorService ?? throw new InvalidOperationException("Не удалось открыть SSH-терминал."); } - private static async Task MaterializePrivateKeyAsync(CancellationToken cancellationToken) + private static async Task MaterializePrivateKeyAsync( + CancellationToken cancellationToken) { var localFolder = ApplicationData.Current.LocalFolder.Path; var protectedKeyPath = Path.Combine(localFolder, "ssh", KeyFileName + ProtectedKeySuffix); var protectedKey = await File.ReadAllBytesAsync(protectedKeyPath, cancellationToken); - var privateKey = ProtectedData.Unprotect(protectedKey, null, DataProtectionScope.CurrentUser); - var temporaryFile = await ApplicationData.Current.TemporaryFolder.CreateFileAsync( - $"{KeyFileName}-{Guid.NewGuid():N}", - CreationCollisionOption.FailIfExists); + byte[]? privateKey = null; try { - await File.WriteAllBytesAsync(temporaryFile.Path, privateKey, cancellationToken); - return temporaryFile.Path; - } - catch - { - File.Delete(temporaryFile.Path); - throw; + privateKey = ProtectedData.Unprotect( + protectedKey, + optionalEntropy: null, + DataProtectionScope.CurrentUser); + return await SshPrivateKeySession.CreateAsync( + ApplicationData.Current.TemporaryFolder.Path, + privateKey, + cancellationToken); } finally { - CryptographicOperations.ZeroMemory(privateKey); + if (privateKey is not null) + { + CryptographicOperations.ZeroMemory(privateKey); + } + CryptographicOperations.ZeroMemory(protectedKey); } } diff --git a/src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs b/src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs new file mode 100644 index 0000000..eea6302 --- /dev/null +++ b/src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs @@ -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 CreateAsync( + string directory, + ReadOnlyMemory 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; + } +} diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj b/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj new file mode 100644 index 0000000..b978a7d --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0-windows10.0.17763.0 + enable + enable + false + + + + + + + + + diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs new file mode 100644 index 0000000..af492c6 --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs @@ -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(() => + 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); + } + } +} diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/SshPrivateKeySessionTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/SshPrivateKeySessionTests.cs new file mode 100644 index 0000000..e494107 --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/SshPrivateKeySessionTests.cs @@ -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() + .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); + } + } +} diff --git a/tests/windows/Test-DesktopContracts.ps1 b/tests/windows/Test-DesktopContracts.ps1 index 3412396..80201c4 100644 --- a/tests/windows/Test-DesktopContracts.ps1 +++ b/tests/windows/Test-DesktopContracts.ps1 @@ -19,6 +19,14 @@ $linksCode = Get-Content -Raw -Encoding UTF8 -LiteralPath ( Join-Path $root 'src\ServerMonitorManager.Desktop\Pages\LinksPage.xaml.cs') $mainCode = Get-Content -Raw -Encoding UTF8 -LiteralPath ( 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 = @( 'x:Name="LinksList"', @@ -39,5 +47,86 @@ if ($mainCode.IndexOf( 'MeshLinksList.SelectedItem = selectedLink;', [StringComparison]::Ordinal) -lt 0) { 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.'