Public Access
Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project: DodoSSH.Contracts is published so clients can build against it, and a package with no licence expression is one a corporate policy scanner rejects outright. The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0 exposes ShellStream.ChangeWindowSize, but a method existing is not the remote observing it, so the tests read `stty size` back from a real sshd after resizing rather than asserting the call did not throw. Repeated resizes each take effect too, which matters because dragging a window edge produces a stream of them. The IChannelSession fallback is not needed. Also verified against a real sshd: password and public-key auth, that the host key arrives as a raw blob we can fingerprint ourselves rather than reading SSH.NET's MD5 property, and that refusing the key via CanTrust actually aborts the connection -- without which the TOFU dialog would be decoration. Kept as a permanent suite, not deleted after the spike. An upgrade that silently stopped sending the request would present as wrapped output only after a resize, which is easy to misattribute to the terminal emulator. Two bugs in the test itself, both worth naming because either would have been read as "resize does not work": - A PTY emits CRLF, and the anchored regex rejected the CR. The output visibly contained `24 80` while the match failed. - Each read can begin with output still buffered from the previous command, including its size line. Taking the first match would have reported the pre-resize size. platform-flags.md now records window-change as resolved rather than unverified -- a stale flag is worse than none -- plus the three real SSH.NET limits found on the way: ShellStream does not override ReadAsync so every idle session parks a pool thread, one connection cannot serve both SshClient and SftpClient, and agent forwarding needs an upstream change.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The SSH layer. Avalonia-free on purpose: this is the seam that makes connection,
|
||||
authentication, PTY handling and host-key trust unit-testable without a UI toolkit.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SSH.NET" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Ssh.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// OpenSSH-style SSH host key fingerprints. See docs/crypto.md §8.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A different thing from a DodoSSH identity fingerprint, and deliberately kept in the format
|
||||
/// users already recognise: <c>SHA256:</c> followed by unpadded standard base64, exactly what
|
||||
/// <c>ssh-keygen -lf</c> prints. A user comparing our string against the one their server
|
||||
/// operator sent them must not have to wonder whether the encodings match.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Computed from the raw host key blob. SSH.NET exposes an MD5 <c>FingerPrint</c> property;
|
||||
/// that is not used — MD5 is banned repo-wide, and no modern operator publishes an MD5 host
|
||||
/// key fingerprint to compare against.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SshHostKeyFingerprint
|
||||
{
|
||||
/// <summary>Prefix identifying the hash algorithm, as OpenSSH writes it.</summary>
|
||||
public const string Prefix = "SHA256:";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a host key blob as <c>SHA256:<base64></c>.
|
||||
/// </summary>
|
||||
/// <param name="hostKeyBlob">
|
||||
/// The raw key blob as it arrived on the wire — the same bytes OpenSSH hashes, not a parsed
|
||||
/// or re-encoded form.
|
||||
/// </param>
|
||||
public static string Format(ReadOnlySpan<byte> hostKeyBlob)
|
||||
{
|
||||
if (hostKeyBlob.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("A host key blob is required.", nameof(hostKeyBlob));
|
||||
}
|
||||
|
||||
Span<byte> digest = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(hostKeyBlob, digest);
|
||||
|
||||
// Standard base64, not base64url, and unpadded — OpenSSH's exact rendering. Using the
|
||||
// URL alphabet here would produce a string that looks right and never matches.
|
||||
return Prefix + Convert.ToBase64String(digest).TrimEnd('=');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two fingerprints in constant time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fingerprint is not a secret, so this is not about a timing oracle. It is about refusing
|
||||
/// to let a mismatch be decided by ordinal string comparison somewhere that later gets
|
||||
/// "optimised" into a case-insensitive or culture-aware comparison — base64 is
|
||||
/// case-sensitive, and a case-insensitive match here would accept a different key.
|
||||
/// </remarks>
|
||||
public static bool Equal(string? left, string? right)
|
||||
{
|
||||
if (left is null || right is null || left.Length != right.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(left),
|
||||
System.Text.Encoding.UTF8.GetBytes(right));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user