diff --git a/Directory.Build.props b/Directory.Build.props
index 9e6faf9..9300cc3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -37,6 +37,13 @@
DodoTech
DodoSSH
en
+ Copyright (c) 2026 Jaap-Jan de Wit (DodoTech)
+
+ MIT
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4977bbd..680b22a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -64,6 +64,17 @@
+
+
+
+
+
@@ -86,6 +97,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs b/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs
new file mode 100644
index 0000000..15a3bab
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs
@@ -0,0 +1,68 @@
+using System.Security.Cryptography;
+
+namespace DodoSSH.Client.Ssh;
+
+///
+/// OpenSSH-style SSH host key fingerprints. See docs/crypto.md §8.
+///
+///
+///
+/// A different thing from a DodoSSH identity fingerprint, and deliberately kept in the format
+/// users already recognise: SHA256: followed by unpadded standard base64, exactly what
+/// ssh-keygen -lf prints. A user comparing our string against the one their server
+/// operator sent them must not have to wonder whether the encodings match.
+///
+///
+/// Computed from the raw host key blob. SSH.NET exposes an MD5 FingerPrint property;
+/// that is not used — MD5 is banned repo-wide, and no modern operator publishes an MD5 host
+/// key fingerprint to compare against.
+///
+///
+public static class SshHostKeyFingerprint
+{
+ /// Prefix identifying the hash algorithm, as OpenSSH writes it.
+ public const string Prefix = "SHA256:";
+
+ ///
+ /// Formats a host key blob as SHA256:<base64>.
+ ///
+ ///
+ /// The raw key blob as it arrived on the wire — the same bytes OpenSSH hashes, not a parsed
+ /// or re-encoded form.
+ ///
+ public static string Format(ReadOnlySpan hostKeyBlob)
+ {
+ if (hostKeyBlob.IsEmpty)
+ {
+ throw new ArgumentException("A host key blob is required.", nameof(hostKeyBlob));
+ }
+
+ Span 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('=');
+ }
+
+ ///
+ /// Compares two fingerprints in constant time.
+ ///
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/packages.lock.json b/src/DodoSSH.Client.Ssh/packages.lock.json
new file mode 100644
index 0000000..70f41aa
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/packages.lock.json
@@ -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=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DodoSSH.Api.Tests/packages.lock.json b/tests/DodoSSH.Api.Tests/packages.lock.json
index ed006cb..a73efe1 100644
--- a/tests/DodoSSH.Api.Tests/packages.lock.json
+++ b/tests/DodoSSH.Api.Tests/packages.lock.json
@@ -1429,15 +1429,6 @@
"resolved": "1.0.5",
"contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q=="
},
- "SSH.NET": {
- "type": "Transitive",
- "resolved": "2025.1.0",
- "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
- "dependencies": {
- "BouncyCastle.Cryptography": "2.6.2",
- "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
- }
- },
"Stef.Validation": {
"type": "Transitive",
"resolved": "0.3.0",
@@ -1470,18 +1461,6 @@
"System.CodeDom": "6.0.0"
}
},
- "Testcontainers": {
- "type": "Transitive",
- "resolved": "4.13.0",
- "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
- "dependencies": {
- "Docker.DotNet.Enhanced": "4.3.3",
- "Docker.DotNet.Enhanced.X509": "4.3.3",
- "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
- "SSH.NET": "2025.1.0",
- "SharpZipLib": "1.4.2"
- }
- },
"TinyMapper.Signed": {
"type": "Transitive",
"resolved": "4.0.0",
@@ -1803,6 +1782,29 @@
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "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"
+ }
+ },
+ "Testcontainers": {
+ "type": "CentralTransitive",
+ "requested": "[4.13.0, )",
+ "resolved": "4.13.0",
+ "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced": "4.3.3",
+ "Docker.DotNet.Enhanced.X509": "4.3.3",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "SSH.NET": "2025.1.0",
+ "SharpZipLib": "1.4.2"
+ }
}
}
}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj b/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj
new file mode 100644
index 0000000..e254a99
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs b/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs
new file mode 100644
index 0000000..f2358f5
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs
@@ -0,0 +1,249 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+
+namespace DodoSSH.Client.Ssh.Tests;
+
+///
+/// The M1 client gate: does a PTY resize actually reach the remote?
+///
+///
+///
+/// The plan flagged window-change on ShellStream as unverified, and it decides the
+/// terminal's whole design — a terminal that cannot resize is unusable, and the fallback is
+/// dropping to IChannelSession and driving the channel requests directly. SSH.NET 2025.1.0
+/// does expose ChangeWindowSize, but a method existing is not the remote observing it, so
+/// these read the size back from the server rather than asserting the call did not throw.
+///
+///
+/// Kept as a permanent regression suite rather than deleted after the spike. An SSH.NET upgrade
+/// that silently stopped sending the request would otherwise ship, and the symptom — wrapped
+/// output only after the user resizes the window — is easy to blame on the terminal emulator.
+///
+///
+[Collection(SshCollection.Name)]
+public sealed class PtyAndResizeSpikeTests(SshServerFixture fixture)
+{
+ private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(20);
+
+ ///
+ /// Matches busybox stty size output: rows then columns.
+ ///
+ ///
+ /// Surrounding horizontal whitespace is tolerated, and the caller normalises CR to LF first.
+ /// A PTY emits CRLF, so an anchored pattern that does not account for the CR fails on output
+ /// that is visibly correct — which reads as "the resize did not work" and sends you looking in
+ /// entirely the wrong place.
+ ///
+ private static readonly Regex SizeLine = new(
+ @"^[ \t]*(?[0-9]{1,5})[ \t]+(?[0-9]{1,5})[ \t]*$",
+ RegexOptions.Multiline | RegexOptions.ExplicitCapture,
+ TimeSpan.FromSeconds(1));
+
+ [Fact]
+ public async Task PasswordAuthentication_Connects()
+ {
+ using var client = CreatePasswordClient();
+
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ client.IsConnected.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task PublicKeyAuthentication_Connects()
+ {
+ // PKCS#1 PEM ("BEGIN RSA PRIVATE KEY"), which SSH.NET has read for years. The vault stores
+ // key material in its own format, so this is only about SSH.NET accepting a standard PEM.
+ var pem = fixture.ClientKey.ExportRSAPrivateKeyPem();
+ using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(pem));
+ using var privateKey = new PrivateKeyFile(keyStream);
+
+ using var client = new SshClient(
+ new ConnectionInfo(
+ fixture.Host,
+ fixture.Port,
+ SshServerFixture.Username,
+ new PrivateKeyAuthenticationMethod(SshServerFixture.Username, privateKey)));
+
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ client.IsConnected.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TheHostKey_ArrivesAsARawBlobWeCanFingerprint()
+ {
+ // Known-hosts entries are a synced vault entity, so the fingerprint we store has to be
+ // computed from the wire blob rather than read off a library property. This confirms the
+ // blob is exposed at all, and that our formatting matches OpenSSH's shape.
+ using var client = CreatePasswordClient();
+
+ byte[]? blob = null;
+ string? algorithm = null;
+
+ client.HostKeyReceived += (_, e) =>
+ {
+ blob = e.HostKey;
+ algorithm = e.HostKeyName;
+ };
+
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ blob.ShouldNotBeNull();
+ blob.Length.ShouldBeGreaterThan(0);
+ algorithm.ShouldNotBeNullOrEmpty();
+
+ var fingerprint = SshHostKeyFingerprint.Format(blob);
+
+ fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
+ fingerprint.ShouldNotEndWith("=");
+
+ // 32 bytes of SHA-256 render as 43 unpadded base64 characters.
+ fingerprint.Length.ShouldBe(SshHostKeyFingerprint.Prefix.Length + 43);
+ }
+
+ [Fact]
+ public async Task RefusingTheHostKey_AbortsTheConnection()
+ {
+ // The mismatch path has to be a hard block, not a warning. If CanTrust were advisory the
+ // TOFU dialog would be decoration.
+ using var client = CreatePasswordClient();
+
+ client.HostKeyReceived += (_, e) => e.CanTrust = false;
+
+ await Should.ThrowAsync(
+ async () => await client.ConnectAsync(TestContext.Current.CancellationToken));
+
+ client.IsConnected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task APtyShell_ReportsTheSizeItWasCreatedWith()
+ {
+ using var client = CreatePasswordClient();
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096);
+
+ var (rows, columns) = await ReadSizeAsync(shell);
+
+ rows.ShouldBe(24);
+ columns.ShouldBe(80);
+ }
+
+ [Fact]
+ public async Task ChangeWindowSize_IsObservedByTheRemote()
+ {
+ // The gate. Reads the size back from the server after resizing, so this fails if
+ // ChangeWindowSize silently does nothing.
+ using var client = CreatePasswordClient();
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096);
+
+ var initial = await ReadSizeAsync(shell);
+ initial.ShouldBe((24, 80));
+
+ shell.ChangeWindowSize(columns: 132, rows: 43, width: 1320, height: 1075);
+
+ var resized = await ReadSizeAsync(shell);
+
+ resized.ShouldBe(
+ (43, 132),
+ "The remote did not observe the window-change request. ShellStream.ChangeWindowSize is "
+ + "not usable, and the terminal must drive IChannelSession directly instead.");
+ }
+
+ [Fact]
+ public async Task ChangeWindowSize_CanBeCalledRepeatedly()
+ {
+ // A user dragging a window edge produces a stream of these. If only the first took effect,
+ // the terminal would settle on whatever size the drag started at.
+ using var client = CreatePasswordClient();
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096);
+ await ReadSizeAsync(shell);
+
+ foreach (var (columns, rows) in new[] { (100u, 30u), (120u, 40u), (90u, 25u) })
+ {
+ shell.ChangeWindowSize(columns, rows, columns * 10, rows * 25);
+
+ var observed = await ReadSizeAsync(shell);
+ observed.ShouldBe(((int)rows, (int)columns));
+ }
+ }
+
+ // ---- Helpers ----
+
+ private SshClient CreatePasswordClient() =>
+ new(fixture.Host, fixture.Port, SshServerFixture.Username, SshServerFixture.Password);
+
+ /// Asks the remote what size it thinks the terminal is.
+ ///
+ /// The marker is written split — "MAR""KER" — so the PTY's echo of the command line
+ /// does not contain it. Without that the read completes on the echo and never sees the output,
+ /// which looks exactly like a resize that did not take effect.
+ ///
+ private static async Task<(int Rows, int Columns)> ReadSizeAsync(ShellStream shell)
+ {
+ shell.WriteLine("""stty size; echo "MAR""KER" """);
+
+ var output = (await ReadUntilAsync(shell, "MARKER")).Replace('\r', '\n');
+
+ var matches = SizeLine.Matches(output);
+ matches.Count.ShouldBeGreaterThan(0, $"No 'rows cols' line in output:\n{output}");
+
+ // The last match, not the first. A read can begin with output still buffered from the
+ // previous command, including its size line — taking the first match would report the size
+ // from before the resize and turn a working resize into a failing test.
+ var match = matches[^1];
+
+ return (
+ int.Parse(match.Groups["rows"].Value, CultureInfo.InvariantCulture),
+ int.Parse(match.Groups["columns"].Value, CultureInfo.InvariantCulture));
+ }
+
+ ///
+ /// Raced against a timeout rather than cancelled. ShellStream does not override
+ /// ReadAsync, so the base implementation runs the blocking read on a pool thread and
+ /// cannot observe a token once it has started — passing one would produce a test that hangs
+ /// instead of failing. The abandoned read dies with the process.
+ ///
+ private static async Task ReadUntilAsync(ShellStream shell, string marker)
+ {
+ var accumulated = new StringBuilder();
+ var deadline = TimeProvider.System.GetUtcNow() + ReadTimeout;
+
+ while (TimeProvider.System.GetUtcNow() < deadline)
+ {
+ var buffer = new byte[4096];
+ var read = shell.ReadAsync(buffer.AsMemory()).AsTask();
+
+ var remaining = deadline - TimeProvider.System.GetUtcNow();
+ if (remaining <= TimeSpan.Zero || await Task.WhenAny(read, Task.Delay(remaining)) != read)
+ {
+ break;
+ }
+
+ var count = await read;
+ if (count == 0)
+ {
+ break;
+ }
+
+ accumulated.Append(Encoding.UTF8.GetString(buffer, 0, count));
+
+ if (accumulated.ToString().Contains(marker, StringComparison.Ordinal))
+ {
+ return accumulated.ToString();
+ }
+ }
+
+ throw new TimeoutException(
+ $"Did not see '{marker}' within {ReadTimeout}. Output so far:\n{accumulated}");
+ }
+}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
new file mode 100644
index 0000000..d50eace
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
@@ -0,0 +1,132 @@
+using System.Security.Cryptography;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Xunit;
+
+namespace DodoSSH.Client.Ssh.Tests;
+
+///
+/// A real OpenSSH server in a container, with password and public-key auth both enabled.
+///
+///
+///
+/// One container per assembly. Everything the SSH layer needs to be right about — PTY
+/// allocation, window-change requests, host key encoding, key formats — is behaviour of a real
+/// sshd, and none of it can be established against a mock.
+///
+///
+/// The image is Alpine-based, so stty comes from busybox. Its stty size prints
+/// "rows cols", which is what the resize assertions read.
+///
+///
+public sealed class SshServerFixture : IAsyncLifetime
+{
+ /// The account tests authenticate as.
+ public const string Username = "dodo";
+
+ /// Password for password authentication.
+ public const string Password = "correct-horse-battery-staple";
+
+ private const int SshPort = 2222;
+
+ private IContainer? container;
+
+ /// Host port the container's sshd is published on.
+ public ushort Port => container!.GetMappedPublicPort(SshPort);
+
+ /// Host the container is reachable at.
+ public string Host => container!.Hostname;
+
+ /// An RSA key pair whose public half is authorized on the server.
+ public RSA ClientKey { get; } = RSA.Create(3072);
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ container = new ContainerBuilder("linuxserver/openssh-server:latest")
+ .WithEnvironment("PUID", "1000")
+ .WithEnvironment("PGID", "1000")
+ .WithEnvironment("USER_NAME", Username)
+ .WithEnvironment("USER_PASSWORD", Password)
+ .WithEnvironment("PASSWORD_ACCESS", "true")
+ .WithEnvironment("SUDO_ACCESS", "false")
+ .WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
+ .WithPortBinding(SshPort, assignRandomHostPort: true)
+
+ // The entrypoint generates host keys, rewrites sshd_config and installs the authorized
+ // key before sshd is usable, so a published port is not readiness. Both conditions are
+ // needed: the log line proves the authorized key was installed (a connection accepted
+ // before that fails public-key auth), and the port check proves sshd is actually
+ // accepting. The message is this image's own wording — "Server listening on" is
+ // OpenSSH's and never appears here, which is a wait that hangs rather than fails.
+ .WithWaitStrategy(Wait.ForUnixContainer()
+ .UntilMessageIsLogged("Public key from env variable added")
+ .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
+
+ .Build();
+
+ await container.StartAsync();
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (container is not null)
+ {
+ await container.DisposeAsync();
+ }
+
+ ClientKey.Dispose();
+ }
+
+ ///
+ /// Renders an RSA public key in the single-line authorized_keys format.
+ ///
+ ///
+ /// Hand-encoded because there is no BCL helper. The SSH wire format is a sequence of
+ /// length-prefixed strings: the algorithm name, then the exponent, then the modulus — both
+ /// as signed big-endian integers, which is why a leading zero byte is prepended when the
+ /// high bit is set. Getting that wrong yields a key sshd silently ignores.
+ ///
+ private static string ExportOpenSshPublicKey(RSA rsa)
+ {
+ var parameters = rsa.ExportParameters(includePrivateParameters: false);
+
+ using var blob = new MemoryStream();
+ WriteSshString(blob, "ssh-rsa"u8.ToArray());
+ WriteSshMpint(blob, parameters.Exponent!);
+ WriteSshMpint(blob, parameters.Modulus!);
+
+ return $"ssh-rsa {Convert.ToBase64String(blob.ToArray())} dodossh-test";
+ }
+
+ private static void WriteSshString(Stream destination, byte[] value)
+ {
+ Span length = stackalloc byte[4];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(length, (uint)value.Length);
+ destination.Write(length);
+ destination.Write(value);
+ }
+
+ private static void WriteSshMpint(Stream destination, byte[] value)
+ {
+ // Signed big-endian: a high bit set would otherwise read as negative.
+ if (value.Length > 0 && (value[0] & 0x80) != 0)
+ {
+ var padded = new byte[value.Length + 1];
+ value.CopyTo(padded, 1);
+ WriteSshString(destination, padded);
+ return;
+ }
+
+ WriteSshString(destination, value);
+ }
+}
+
+/// Shares one SSH server across every test class in the assembly.
+[CollectionDefinition(Name)]
+public sealed class SshCollection : ICollectionFixture
+{
+ /// Collection name.
+ public const string Name = "ssh";
+}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json b/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json
new file mode 100644
index 0000000..17238da
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json
@@ -0,0 +1,313 @@
+{
+ "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=="
+ },
+ "NSubstitute": {
+ "type": "Direct",
+ "requested": "[6.0.0, )",
+ "resolved": "6.0.0",
+ "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
+ "dependencies": {
+ "Castle.Core": "5.1.1"
+ }
+ },
+ "Shouldly": {
+ "type": "Direct",
+ "requested": "[4.3.0, )",
+ "resolved": "4.3.0",
+ "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
+ "dependencies": {
+ "DiffEngine": "11.3.0",
+ "EmptyFiles": "4.4.0"
+ }
+ },
+ "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"
+ }
+ },
+ "Testcontainers": {
+ "type": "Direct",
+ "requested": "[4.13.0, )",
+ "resolved": "4.13.0",
+ "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced": "4.3.3",
+ "Docker.DotNet.Enhanced.X509": "4.3.3",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "SSH.NET": "2025.1.0",
+ "SharpZipLib": "1.4.2"
+ }
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Castle.Core": {
+ "type": "Transitive",
+ "resolved": "5.1.1",
+ "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "6.0.0"
+ }
+ },
+ "DiffEngine": {
+ "type": "Transitive",
+ "resolved": "11.3.0",
+ "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
+ "dependencies": {
+ "EmptyFiles": "4.4.0",
+ "System.Management": "6.0.1"
+ }
+ },
+ "Docker.DotNet.Enhanced": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
+ "Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
+ "Docker.DotNet.Enhanced.NPipe": "4.3.3",
+ "Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
+ "Docker.DotNet.Enhanced.Unix": "4.3.3",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.Handler.Abstractions": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.LegacyHttp": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.NativeHttp": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.NPipe": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.Unix": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
+ }
+ },
+ "Docker.DotNet.Enhanced.X509": {
+ "type": "Transitive",
+ "resolved": "4.3.3",
+ "contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
+ }
+ },
+ "EmptyFiles": {
+ "type": "Transitive",
+ "resolved": "4.4.0",
+ "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "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"
+ }
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "SharpZipLib": {
+ "type": "Transitive",
+ "resolved": "1.4.2",
+ "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
+ },
+ "System.Management": {
+ "type": "Transitive",
+ "resolved": "6.0.1",
+ "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ },
+ "dodossh.client.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DodoSSH.Infrastructure.Tests/packages.lock.json b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json
index 8382bd2..8a8cea6 100644
--- a/tests/DodoSSH.Infrastructure.Tests/packages.lock.json
+++ b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json
@@ -286,15 +286,6 @@
"resolved": "1.4.2",
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
},
- "SSH.NET": {
- "type": "Transitive",
- "resolved": "2025.1.0",
- "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
- "dependencies": {
- "BouncyCastle.Cryptography": "2.6.2",
- "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
- }
- },
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
@@ -313,18 +304,6 @@
"System.CodeDom": "6.0.0"
}
},
- "Testcontainers": {
- "type": "Transitive",
- "resolved": "4.13.0",
- "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
- "dependencies": {
- "Docker.DotNet.Enhanced": "4.3.3",
- "Docker.DotNet.Enhanced.X509": "4.3.3",
- "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
- "SSH.NET": "2025.1.0",
- "SharpZipLib": "1.4.2"
- }
- },
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
@@ -443,6 +422,29 @@
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "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"
+ }
+ },
+ "Testcontainers": {
+ "type": "CentralTransitive",
+ "requested": "[4.13.0, )",
+ "resolved": "4.13.0",
+ "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
+ "dependencies": {
+ "Docker.DotNet.Enhanced": "4.3.3",
+ "Docker.DotNet.Enhanced.X509": "4.3.3",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "SSH.NET": "2025.1.0",
+ "SharpZipLib": "1.4.2"
+ }
}
}
}