Files
DodoSSH/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
jaap-jan 8209f15741 Let a session's transport say what it negotiated
ISshConnection and ISftpSession both carry Cipher now — the server-to-client
algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is
not an event that library raises — and TerminalWorkspace.GetSessionFacts hands
that plus the host key's algorithm back per session, without ever handing over
the connection itself. Nothing reads either yet; the status bar that will is
the next commit.
2026-08-08 20:54:14 +02:00

159 lines
7.5 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Renci.SshNet.Common;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// Public-key authentication through the path the vault actually uses, against a real sshd.
/// </summary>
/// <remarks>
/// <para>
/// <c>PtyAndResizeSpikeTests</c> also authenticates with this fixture's key, and it does so by building
/// SSH.NET's <c>PrivateKeyAuthenticationMethod</c> itself — correct for a spike whose subject is the PTY,
/// and it leaves the application's own path unexercised. What runs here is what a vault-held key goes
/// through: <see cref="SshPrivateKeyCredential"/> carrying PEM bytes rather than a path, into
/// <see cref="SshNetConnectionFactory"/>, which hands them to <c>PrivateKeyFile</c> as a
/// <c>MemoryStream</c>. That indirection is the reason a key in this product never becomes a file on disk,
/// and until now nothing established that it authenticates.
/// </para>
/// <para>
/// The fixture's key is RSA because there is no BCL Ed25519, and the fixture has to render the public half
/// in <c>authorized_keys</c> form to install it. Which algorithm it is does not matter to anything under
/// test here — the client never parses the key, it forwards it.
/// </para>
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class KeyAuthenticationTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell()
{
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
connection.IsConnected.ShouldBeTrue();
// The one place this suite checks Cipher against a real handshake rather than a fake's fixed string.
// SSH.NET negotiates whatever the container's sshd offers first from its own preference list, so the
// exact algorithm is not pinned here — only that ConnectionInfo.CurrentServerEncryption came back as
// something rather than the empty string a stalled or pre-handshake read would produce.
connection.Cipher.ShouldNotBeNullOrEmpty();
// Authenticated is not the same as usable: a channel has to open on the connection too.
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
shell.IsOpen.ShouldBeTrue();
}
[Fact]
public async Task TheSameKeyInPkcs8Armour_AlsoAuthenticates()
{
// SshKeySecret stores whatever armour it was given, verbatim, and declines to normalise it. This is
// the half of that claim which is about SSH.NET rather than the codec: the two commonest forms
// ssh-keygen and openssl produce both load without the client knowing which it has.
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs8(fixture.ClientKey), Passphrase: null));
connection.IsConnected.ShouldBeTrue();
}
[Fact]
public async Task AKeyTheServerDoesNotKnow_FailsAsAnAuthenticationError()
{
// The specific failure worth pinning is a misreport. The host key is already trusted here, so the
// factory's gate must translate nothing and let the authentication error through — if it answered
// with SshHostKeyUnknownException instead, the user would be shown a fingerprint to approve for a
// problem that approving it cannot fix.
using var stranger = RSA.Create(2048);
var knownHosts = await TrustedStoreAsync();
var factory = new SshNetConnectionFactory(knownHosts);
await Should.ThrowAsync<SshAuthenticationException>(async () =>
await factory.ConnectAsync(Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), Token));
}
[Fact]
public async Task APassphraseOnAnUnprotectedKey_IsIgnoredRatherThanRefused()
{
// Written expecting the opposite, and it records what SSH.NET measurably does: PrivateKeyFile
// accepts a passphrase for a key that has none, and the connection authenticates as if it had not
// been given. See docs/platform-flags.md — the consequence is that nothing downstream will catch a
// stray passphrase, so a client that wants that caught has to notice it itself, and a client that
// does not can stop worrying about the case.
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), "a passphrase this key does not have"));
connection.IsConnected.ShouldBeTrue();
}
/// <remarks>
/// <para>
/// <b>The test the key generator exists to pass.</b> Everything else about the hand-written
/// <c>openssh-key-v1</c> container is checked against SSH.NET's own parser, which is the parser this
/// application uses and therefore a fair oracle — but it is still one implementation agreeing with
/// another. This is the one that puts the public half on a real OpenSSH server and authenticates with
/// the private half, which is the only thing anybody actually wants to know.
/// </para>
/// <para>
/// Both algorithms, because they are encoded by entirely different code: Ed25519 goes through the
/// hand-written container, and RSA through the BCL's PKCS#1 export with only the public line
/// hand-encoded. A failure on one says nothing about the other.
/// </para>
/// </remarks>
[Theory]
[InlineData(SshKeyAlgorithm.Ed25519)]
[InlineData(SshKeyAlgorithm.Rsa4096)]
public async Task AKeyThisClientGenerated_AuthenticatesAgainstARealServer(SshKeyAlgorithm algorithm)
{
var generated = SshKeyGenerator.Generate(algorithm, "dodossh@generated");
await fixture.AuthorizeAsync(generated.PublicKeyLine, Token);
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(generated.PrivateKeyArmour), Passphrase: null));
connection.IsConnected.ShouldBeTrue();
// Authenticated is not the same as usable, as above.
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
shell.IsOpen.ShouldBeTrue();
}
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
private SshConnectionRequest Request(SshCredential credential) =>
new(fixture.Host, fixture.Port, SshServerFixture.Username, credential);
/// <summary>A store that already trusts the container's host key, so first contact is not the subject.</summary>
private async Task<InMemoryKnownHostStore> TrustedStoreAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
// Learned by being refused, which is the only way this client learns a host key.
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await factory.ConnectAsync(
Request(new SshPasswordCredential(SshServerFixture.Password)), Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
return knownHosts;
}
private async Task<ISshConnection> ConnectTrustedAsync(SshCredential credential)
{
var knownHosts = await TrustedStoreAsync();
return await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(credential), Token);
}
}