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
|
||||
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: |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -424,6 +424,63 @@ public sealed partial class MainPage : Page
|
|||
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)
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
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.");
|
||||
}
|
||||
|
||||
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<SshHostKeyCandidate> 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<string> MaterializePrivateKeyAsync(CancellationToken cancellationToken)
|
||||
private static async Task<SshPrivateKeySession> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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')
|
||||
$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.'
|
||||
|
|
|
|||
Loading…
Reference in a new issue