using System.Security.Cryptography; using System.Text; using Renci.SshNet.Common; namespace DodoSSH.Client.Ssh.Tests; /// /// Public-key authentication through the path the vault actually uses, against a real sshd. /// /// /// /// PtyAndResizeSpikeTests also authenticates with this fixture's key, and it does so by building /// SSH.NET's PrivateKeyAuthenticationMethod 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: carrying PEM bytes rather than a path, into /// , which hands them to PrivateKeyFile as a /// MemoryStream. That indirection is the reason a key in this product never becomes a file on disk, /// and until now nothing established that it authenticates. /// /// /// The fixture's key is RSA because there is no BCL Ed25519, and the fixture has to render the public half /// in authorized_keys 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. /// /// [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(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(); } /// /// /// The test the key generator exists to pass. Everything else about the hand-written /// openssh-key-v1 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. /// /// /// 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. /// /// [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(); } /// /// /// 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. /// /// /// 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 — /// comes out of SSH.NET's HostKeyReceived, which is the single interior moment the library gives /// anybody, and it has to land between the other two rather than beside them. /// /// /// 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. TerminalWorkspaceTests covers that half. /// /// [Fact] public async Task AHandshake_ReportsItsPhasesInTheOrderTheyHappen() { var knownHosts = await TrustedStoreAsync(); var reported = new List(); await using var connection = await new SshNetConnectionFactory(knownHosts).ConnectAsync( Request(new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null)), new DelegateProgress(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, ]); } } /// /// 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. /// [Fact] public async Task AHostKeyRefusal_NeverClaimsToHaveAuthenticated() { var reported = new List(); await Should.ThrowAsync(async () => await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync( Request(new SshPasswordCredential(SshServerFixture.Password)), new DelegateProgress(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); /// A store that already trusts the container's host key, so first contact is not the subject. private async Task 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(async () => await factory.ConnectAsync( Request(new SshPasswordCredential(SshServerFixture.Password)), progress: null, Token)); await knownHosts.TrustAsync(unknown.Presentation, Token); return knownHosts; } private async Task ConnectTrustedAsync(SshCredential credential) { var knownHosts = await TrustedStoreAsync(); return await new SshNetConnectionFactory(knownHosts) .ConnectAsync(Request(credential), progress: null, Token); } }