fix(security): pin SSH trust and protect session keys (#8)
Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
This commit is contained in:
parent
93e0f8ddbd
commit
c13501e529
11 changed files with 685 additions and 32 deletions
3
.github/workflows/windows-build.yml
vendored
3
.github/workflows/windows-build.yml
vendored
|
|
@ -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: |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
176
src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs
Normal file
176
src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
91
src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs
Normal file
91
src/ServerMonitorManager.Desktop/SshPrivateKeySession.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue