Public Access
278 lines
13 KiB
C#
278 lines
13 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;
|
|
|
|
/// <remarks>
|
|
/// Opening a shell is three round trips to the server, and SSH.NET makes them synchronously. They used to
|
|
/// be made on the caller's thread — the UI thread — behind a <c>Task.FromResult</c>, so the task this
|
|
/// returned was already finished and the window had stopped painting while it was being made.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningAShell_LeavesTheCallingThreadFree()
|
|
{
|
|
await using var connection = await ConnectTrustedAsync(
|
|
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
|
|
|
|
var opening = connection.OpenShellAsync(TerminalSize.Default, Token);
|
|
|
|
opening.IsCompleted.ShouldBeFalse("three round trips to the server cannot have been made on this thread");
|
|
|
|
await using var shell = await opening;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cancellation token used to be read once, before any of those round trips. A caller that gives up
|
|
/// now stops waiting at once, and the connection is left able to open the next shell.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AShellGivenUpOn_StopsTheWait_AndTheConnectionOpensAnother()
|
|
{
|
|
await using var connection = await ConnectTrustedAsync(
|
|
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
|
|
|
|
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
|
|
|
var opening = connection.OpenShellAsync(TerminalSize.Default, giveUp.Token);
|
|
await giveUp.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(opening);
|
|
|
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
|
}
|
|
|
|
[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)), progress: 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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The claim the connecting card is built on, checked where it can actually be checked: against a real
|
|
/// handshake rather than a fake that reports whatever it was written to report. Every other test of the
|
|
/// step list asserts that the shell draws what it is told; this one asserts that what it is told is
|
|
/// true.
|
|
/// </para>
|
|
/// <para>
|
|
/// The order is the assertion. A step list is only readable if the reports arrive in the order it draws
|
|
/// them, and the middle one is the load-bearing part — <see cref="SshConnectionPhase.CheckingHostKey"/>
|
|
/// comes out of SSH.NET's <c>HostKeyReceived</c>, which is the single interior moment the library gives
|
|
/// anybody, and it has to land between the other two rather than beside them.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="SshConnectionPhase.OpeningShell"/> is deliberately absent: this factory's work ends with
|
|
/// an authenticated connection, and the phase for opening a channel on one belongs to the layer that
|
|
/// opens it. <c>TerminalWorkspaceTests</c> covers that half.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AHandshake_ReportsItsPhasesInTheOrderTheyHappen()
|
|
{
|
|
var knownHosts = await TrustedStoreAsync();
|
|
var reported = new List<SshConnectionPhase>();
|
|
|
|
await using var connection = await new SshNetConnectionFactory(knownHosts).ConnectAsync(
|
|
Request(new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null)),
|
|
new DelegateProgress<SshConnectionPhase>(phase =>
|
|
{
|
|
lock (reported)
|
|
{
|
|
// Locked because the last two are reported from SSH.NET's own handshake thread rather
|
|
// than from the awaiting one, which is the whole reason the shell marshals them.
|
|
reported.Add(phase);
|
|
}
|
|
}),
|
|
Token);
|
|
|
|
connection.IsConnected.ShouldBeTrue();
|
|
|
|
lock (reported)
|
|
{
|
|
reported.ShouldBe(
|
|
[
|
|
SshConnectionPhase.Reaching,
|
|
SshConnectionPhase.CheckingHostKey,
|
|
SshConnectionPhase.Authenticating,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the phase contract, and the one that would be easy to get wrong by reporting
|
|
/// optimistically: a refused key stops at the check. Nothing may claim the credential was offered, and
|
|
/// against an unknown host nothing ever is — the gate returns false and SSH.NET abandons the handshake
|
|
/// before authentication.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AHostKeyRefusal_NeverClaimsToHaveAuthenticated()
|
|
{
|
|
var reported = new List<SshConnectionPhase>();
|
|
|
|
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(
|
|
Request(new SshPasswordCredential(SshServerFixture.Password)),
|
|
new DelegateProgress<SshConnectionPhase>(phase =>
|
|
{
|
|
lock (reported)
|
|
{
|
|
reported.Add(phase);
|
|
}
|
|
}),
|
|
Token));
|
|
|
|
lock (reported)
|
|
{
|
|
reported.ShouldBe([SshConnectionPhase.Reaching, SshConnectionPhase.CheckingHostKey]);
|
|
}
|
|
}
|
|
|
|
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)), progress: null, 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), progress: null, Token);
|
|
}
|
|
}
|