176 lines
5.3 KiB
C#
176 lines
5.3 KiB
C#
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}";
|
|
}
|