tmds / Tmds.Ssh

.NET SSH client library
MIT License
177 stars 10 forks source link

gssapi-with-mic Credential Support RFC 4462 #184

Closed jborean93 closed 4 months ago

jborean93 commented 4 months ago

I'm not aware what work is involved but it would be great if this supported the gssapi-with-mic auth protocol https://datatracker.ietf.org/doc/html/rfc4462. .NET has the NegotiateAuthentication which wraps SSPI on Windows and GSSAPI on non-Windows which should hopefully offer the needed calls to do both authentication and wrapping that's required. I have a suspicion that it may not expose enough detail to achieve using the builtin class, like getting the MIC, or supporting delegation but maybe those are optional extras and .NET could expose a flag for those features in future versions.

jborean93 commented 4 months ago

Looks like .NET 9 added the MIC capabilities with https://github.com/dotnet/runtime/pull/96712 through NegotiateAuthentication.ComputeIntegrityCheck-system-buffers-ibufferwriter((system-byte)))). The delegation option is covered by NegotiateAuthenticationServerOptions.RequiredImpersonationLevel set to Delegation https://github.com/dotnet/runtime/blob/3073a0326a11bfe17776c4ff3c172176dedfe860/src/libraries/System.Net.Security/src/System/Net/NegotiateAuthenticationPal.Unix.cs#L180 so we are all good there!

jborean93 commented 4 months ago

I've worked on this a bit and was able to get it working. Here is a diff with the basic changes needed. More work would need to be done to deal with error handling and more tidying up of the code. It also uses reflection for .NET 8 as the MIC method was not public until .NET 9.

Expand to get diff ```diff diff --git a/src/Tmds.Ssh/GssapiWithMicCredential.cs b/src/Tmds.Ssh/GssapiWithMicCredential.cs new file mode 100644 index 0000000..b90e577 --- /dev/null +++ b/src/Tmds.Ssh/GssapiWithMicCredential.cs @@ -0,0 +1,253 @@ +// This file is part of Tmds.Ssh which is released under MIT. +// See file LICENSE for full license details. + +using System; +using System.Buffers; +using System.Linq; +using System.Net; +using System.Net.Security; +using System.Reflection; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Tmds.Ssh; + +public sealed class GssapiWithMicCredential : Credential +{ + // RFC: https://datatracker.ietf.org/doc/html/rfc4462 + // 1.2.840.113554.1.2.2 + private static readonly byte[] _krb5Oid = [ 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x12, 0x01, 0x02, 0x02 ]; + +#if !NET9_0_OR_GREATER + private delegate void GetMICDelegate(ReadOnlySpan data, IBufferWriter writer); + + private static readonly MethodInfo _getMicMethInfo = typeof(NegotiateAuthentication).GetMethod( + "GetMIC", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("NegotiateAuthentication.GetMIC method not found."); +#endif + + private readonly Func _getPassword; + + internal string? GetPassword() => _getPassword(); + + internal bool DelegateCredential { get; } + + internal string? ServiceName { get; } + + public GssapiWithMicCredential(string? password = null, bool delegateCredential = false, string? serviceName = null) : this(() => password, delegateCredential, serviceName) + { } + + public GssapiWithMicCredential(Func passwordPrompt, bool delegateCredential = false, string? serviceName = null) + { + _getPassword = passwordPrompt; + DelegateCredential = delegateCredential; + ServiceName = serviceName; + } + + internal async Task TryAuthenticate(SshConnection connection, ILogger logger, SshClientSettings settings, SshConnectionInfo connectionInfo, CancellationToken ct) + { + bool isOidSuccess = await TryStageOid(connection, settings.UserName, ct).ConfigureAwait(false); + if (!isOidSuccess) + { + return false; + } + + var negotiateOptions = new NegotiateAuthenticationClientOptions() + { + AllowedImpersonationLevel = DelegateCredential ? TokenImpersonationLevel.Delegation : TokenImpersonationLevel.Impersonation, + Package = "Kerberos", + RequiredProtectionLevel = ProtectionLevel.Sign, + // While RFC states this should be set to "false", Win32-OpenSSH + // fails if it's not true. I'm unsure if openssh-portable on Linux + // will fail in the same way or not. + RequireMutualAuthentication = true, + TargetName = ServiceName ?? $"host@{connectionInfo.Host}", + }; + + string? password = GetPassword(); + if (password is not null) + { + negotiateOptions.Credential = new NetworkCredential(settings.UserName, password); + } + + using var authContext = new NegotiateAuthentication(negotiateOptions); + bool isAuthSuccess = await TryStageAuthentication(connection, authContext, ct).ConfigureAwait(false); + if (!isAuthSuccess) + { + return false; + } + + if (authContext.IsSigned) + { + return await TryStageMic(connection, connectionInfo.SessionId!, settings.UserName, authContext, ct).ConfigureAwait(false); + } + else + { + { + using var completeMessage = CreateGssapiCompleteMessage(connection.SequencePool); + await connection.SendPacketAsync(completeMessage.Move(), ct).ConfigureAwait(false); + } + + return true; + } + } + + private async Task TryStageOid(SshConnection connection, string userName, CancellationToken ct) + { + { + using var userAuthMsg = CreateOidRequestMessage(connection.SequencePool, + userName, _krb5Oid); + await connection.SendPacketAsync(userAuthMsg.Move(), ct).ConfigureAwait(false); + } + + using Packet response = await connection.ReceivePacketAsync(ct).ConfigureAwait(false); + ReadOnlySequence? oidResponse = GetGssapiOidResponse(response); + + if (oidResponse is null) + { + return false; + } + + return _krb5Oid.AsSpan().SequenceEqual( + oidResponse.Value.IsSingleSegment ? oidResponse.Value.FirstSpan : oidResponse.Value.ToArray()); + } + + private async Task TryStageAuthentication(SshConnection connection, NegotiateAuthentication authContext, CancellationToken ct) + { + byte[]? outToken = authContext.GetOutgoingBlob(Array.Empty(), out var statusCode); + while (outToken is not null) + { + { + using var userAuthMsg = CreateGssapiTokenMessage(connection.SequencePool, outToken); + await connection.SendPacketAsync(userAuthMsg.Move(), ct).ConfigureAwait(false); + } + + // If the context is complete we don't expect a response. + if (statusCode == NegotiateAuthenticationStatusCode.Completed) + { + break; + } + + // If not complete we expect the response input token to continue the auth. + using Packet response = await connection.ReceivePacketAsync(ct).ConfigureAwait(false); + ReadOnlySequence? tokenResponse = GetGssapiTokenResponse(response); + if (tokenResponse is null) + { + break; + } + + outToken = authContext.GetOutgoingBlob( + tokenResponse.Value.IsSingleSegment ? tokenResponse.Value.FirstSpan : tokenResponse.Value.ToArray(), + out statusCode); + } + + return statusCode == NegotiateAuthenticationStatusCode.Completed; + } + + private async Task TryStageMic(SshConnection connection, byte[] sessionId, string userName, NegotiateAuthentication authContext, CancellationToken ct) + { + byte[] micData = CreateGssapiMicData(connection.SequencePool, sessionId, userName); + var signatureWriter = new ArrayBufferWriter(); + +#if NET9_0_OR_GREATER + auth.ComputeIntegrityCheck(data, signatureWriter); +#else + _getMicMethInfo.CreateDelegate(authContext)(micData, signatureWriter); +#endif + { + using var micMessage = CreateGssapiMicMessage(connection.SequencePool, signatureWriter.WrittenSpan); + await connection.SendPacketAsync(micMessage.Move(), ct).ConfigureAwait(false); + } + + return true; + } + + + private static Packet CreateOidRequestMessage(SequencePool sequencePool, string userName, ReadOnlySpan oid) + { + using var packet = sequencePool.RentPacket(); + var writer = packet.GetWriter(); + writer.WriteMessageId(MessageId.SSH_MSG_USERAUTH_REQUEST); + writer.WriteString(userName); + writer.WriteString("ssh-connection"); + writer.WriteString("gssapi-with-mic"); + writer.WriteUInt32(1); + writer.WriteString(oid); + return packet.Move(); + } + + private static Packet CreateGssapiTokenMessage(SequencePool sequencePool, ReadOnlySpan token) + { + using var packet = sequencePool.RentPacket(); + var writer = packet.GetWriter(); + writer.WriteMessageId(MessageId.SSH_MSG_USERAUTH_GSSAPI_TOKEN); + writer.WriteString(token); + return packet.Move(); + } + + private static Packet CreateGssapiMicMessage(SequencePool sequencePool, ReadOnlySpan mic) + { + using var packet = sequencePool.RentPacket(); + var writer = packet.GetWriter(); + writer.WriteMessageId(MessageId.SSH_MSG_USERAUTH_GSSAPI_MIC); + writer.WriteString(mic); + return packet.Move(); + } + + private static Packet CreateGssapiCompleteMessage(SequencePool sequencePool) + { + using var packet = sequencePool.RentPacket(); + var writer = packet.GetWriter(); + writer.WriteMessageId(MessageId.SSH_MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE); + return packet.Move(); + } + + private static byte[] CreateGssapiMicData(SequencePool sequencePool, ReadOnlySpan sessionId, string userName) + { + using var packet = sequencePool.RentPacket(); + var writer = packet.GetWriter(); + writer.WriteString(sessionId); + writer.WriteMessageId(MessageId.SSH_MSG_USERAUTH_REQUEST); + writer.WriteString(userName); + writer.WriteString("ssh-connection"); + writer.WriteString("gssapi-with-mic"); + + // The MIC data does not include the header, so skip the first 5 bytes. + return packet.Move().AsReadOnlySequence().Slice(5).ToArray(); + } + + private static ReadOnlySequence? GetGssapiOidResponse(ReadOnlyPacket packet) + { + var reader = packet.GetReader(); + MessageId b = reader.ReadMessageId(); + switch (b) + { + case MessageId.SSH_MSG_USERAUTH_GSSAPI_RESPONSE: + return reader.ReadStringAsBytes(); + case MessageId.SSH_MSG_USERAUTH_FAILURE: + return null; + default: + ThrowHelper.ThrowProtocolUnexpectedValue(); + return null; + } + } + + private static ReadOnlySequence? GetGssapiTokenResponse(ReadOnlyPacket packet) + { + var reader = packet.GetReader(); + MessageId b = reader.ReadMessageId(); + switch (b) + { + case MessageId.SSH_MSG_USERAUTH_GSSAPI_TOKEN: + return reader.ReadStringAsBytes(); + case MessageId.SSH_MSG_USERAUTH_FAILURE: + return null; + default: + ThrowHelper.ThrowProtocolUnexpectedValue(); + return null; + } + } +} diff --git a/src/Tmds.Ssh/MessageId.cs b/src/Tmds.Ssh/MessageId.cs index e811a42..f767705 100644 --- a/src/Tmds.Ssh/MessageId.cs +++ b/src/Tmds.Ssh/MessageId.cs @@ -19,6 +19,12 @@ enum MessageId SSH_MSG_USERAUTH_FAILURE = 51, SSH_MSG_USERAUTH_SUCCESS = 52, SSH_MSG_USERAUTH_BANNER = 53, + SSH_MSG_USERAUTH_GSSAPI_RESPONSE = 60, + SSH_MSG_USERAUTH_GSSAPI_TOKEN = 61, + SSH_MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE = 63, + SSH_MSG_USERAUTH_GSSAPI_ERROR = 64, + SSH_MSG_USERAUTH_GSSAPI_ERRTOK = 65, + SSH_MSG_USERAUTH_GSSAPI_MIC = 66, SSH_MSG_GLOBAL_REQUEST = 80, SSH_MSG_REQUEST_SUCCESS = 81, SSH_MSG_REQUEST_FAILURE = 82, diff --git a/src/Tmds.Ssh/UserAuthentication.cs b/src/Tmds.Ssh/UserAuthentication.cs index bdba501..28a092e 100644 --- a/src/Tmds.Ssh/UserAuthentication.cs +++ b/src/Tmds.Ssh/UserAuthentication.cs @@ -85,6 +85,20 @@ private async static Task PerformDefaultAuthentication(SshConnection connection, throw new PrivateKeyLoadException(filename, error); } } + else if (credential is GssapiWithMicCredential gssapiCredential) + { + bool isGssapiSuccessful = await gssapiCredential.TryAuthenticate(connection, logger, settings, connectionInfo, ct).ConfigureAwait(false); + if (!isGssapiSuccessful) + { + continue; + } + + bool isAuthSuccesfull = await ReceiveAuthIsSuccesfullAsync(connection, logger, ct).ConfigureAwait(false); + if (isAuthSuccesfull) + { + return; + } + } else { throw new NotImplementedException("Unsupported credential type: " + credential.GetType().FullName); ```
tmds commented 4 months ago

Nice work!

Can you open a PR?

jborean93 commented 4 months ago

I can certainly try and tidy it up but I’m unsure what your feeling are around the use of reflection to support .NET 8 and how to deal with the changes in .NET 9 considering only net8.0 is built right now. Testing may also be problematic as while the container can be configured to support Kerberos auth I’m not sure how you want the client side to work if the krb5 libraries are not present.

tmds commented 4 months ago

We can figure these things out as we work on the PR. Here are some thoughts.

use of reflection to support .NET 8 and how to deal with the changes in .NET 9 considering only net8.0 is built right now.

We can add a net9.0 target. And for net8.0, I'm fine with using reflection if it is fairly limited, which seems to be the case.

I’m not sure how you want the client side to work if the krb5 libraries are not present.

There could be something like a static bool IsSupported { get; } that tells the user if dependencies are met to use the credential type.

Testing

It would be nice if we can have a test that works in the GitHub CI by installing the necessary dependencies. The test can be skipped when the IsSupported condition isn't met.

jborean93 commented 4 months ago

Thanks, I’ll look at making the changes PR ready. Appreciate the feedback!