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:
jaap-jan committed 2026-07-28 16:52:09 +02:00
1 parent b7325b78ca
commit 885fb17bdc
15 files changed
+956 -46

No files matched your search

@@ -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:&lt;base64&gt;</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));
}
}