diff --git a/Kerberos.NET/Client/IAKerb/IAKerbContextResult.cs b/Kerberos.NET/Client/IAKerb/IAKerbContextResult.cs new file mode 100644 index 00000000..157bd4d2 --- /dev/null +++ b/Kerberos.NET/Client/IAKerb/IAKerbContextResult.cs @@ -0,0 +1,40 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; + +namespace Kerberos.NET.Client +{ + /// + /// The result of a single step of IAKerb context establishment. + /// + public class IAKerbContextResult + { + /// + /// The GSS token to send to the peer. Always present. + /// + public ReadOnlyMemory Token { get; } + + /// + /// Whether the context establishment is still in progress. + /// When true, send to the peer and feed their response + /// into the next call. When false, authentication is complete. + /// + public bool ContinueNeeded { get; } + + /// + /// The session context for the authenticated connection. + /// Only available when is false. + /// + public ApplicationSessionContext SessionContext { get; } + + internal IAKerbContextResult(ReadOnlyMemory token, bool continueNeeded, ApplicationSessionContext sessionContext = null) + { + this.Token = token; + this.ContinueNeeded = continueNeeded; + this.SessionContext = sessionContext; + } + } +} diff --git a/Kerberos.NET/Client/IAKerb/IAKerbInitiator.cs b/Kerberos.NET/Client/IAKerb/IAKerbInitiator.cs new file mode 100644 index 00000000..29b70497 --- /dev/null +++ b/Kerberos.NET/Client/IAKerb/IAKerbInitiator.cs @@ -0,0 +1,305 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Kerberos.NET.Configuration; +using Kerberos.NET.Credentials; +using Kerberos.NET.Crypto; +using Kerberos.NET.Entities; + +namespace Kerberos.NET.Client +{ + /// + /// The current state of the IAKerb initiator context. + /// + public enum IAKerbInitiatorState + { + /// + /// Context has not started. + /// + NotStarted, + + /// + /// The client is exchanging KDC messages through the proxy. + /// + InProgress, + + /// + /// Context establishment is complete. + /// + Complete, + + /// + /// Context establishment failed. + /// + Failed + } + + /// + /// IAKerb client-side initiator. Wraps a KerberosClient with an IAKerb transport + /// to produce IAKERB_PROXY GSS tokens instead of communicating directly with the KDC. + /// + /// Usage: + /// + /// var initiator = new IAKerbInitiator(credential, spn); + /// var result = await initiator.InitSecurityContext(); + /// while (result.ContinueNeeded) + /// { + /// var serverToken = SendToServer(result.Token); // application-specific + /// result = await initiator.InitSecurityContext(serverToken); + /// } + /// // result.Token is the final AP-REQ; result.SessionContext has the session keys + /// + /// + public class IAKerbInitiator : IDisposable + { + private static readonly Oid IAKerbOid = new(MechType.IAKerb); + + private readonly KerberosClient client; + private readonly IAKerbTransport transport; + private readonly KerberosCredential credential; + private readonly string servicePrincipalName; + private readonly MemoryStream transcript = new(); + + private Task authenticateTask; + private Task serviceTicketTask; + private ReadOnlyMemory? cookie; + private bool disposed; + + /// + /// The current state of the initiator. + /// + public IAKerbInitiatorState State { get; private set; } = IAKerbInitiatorState.NotStarted; + + /// + /// The AP options to use when creating the final AP-REQ. + /// + public ApOptions ApOptions { get; set; } = ApOptions.MutualRequired; + + /// + /// The GSS context flags to use for the security context. + /// + public GssContextEstablishmentFlag GssContextFlags { get; set; } = + GssContextEstablishmentFlag.GSS_C_REPLAY_FLAG | + GssContextEstablishmentFlag.GSS_C_SEQUENCE_FLAG | + GssContextEstablishmentFlag.GSS_C_CONF_FLAG | + GssContextEstablishmentFlag.GSS_C_INTEG_FLAG | + GssContextEstablishmentFlag.GSS_C_EXTENDED_ERROR_FLAG; + + /// + /// The session context after successful authentication. + /// Only available when is . + /// + public ApplicationSessionContext SessionContext { get; private set; } + + /// + /// Creates a new IAKerb initiator that will authenticate using the given credential + /// and request a service ticket for the specified SPN. + /// + /// The credential to authenticate with. + /// The SPN of the target service. + /// Optional Kerberos configuration. + public IAKerbInitiator( + KerberosCredential credential, + string servicePrincipalName, + Krb5Config config = null + ) + { + this.credential = credential ?? throw new ArgumentNullException(nameof(credential)); + this.servicePrincipalName = servicePrincipalName ?? throw new ArgumentNullException(nameof(servicePrincipalName)); + + this.transport = new IAKerbTransport(); + this.client = new KerberosClient(config, transports: this.transport); + } + + /// + /// Performs one step of the IAKerb GSS-API context establishment. + /// Call with null inputToken for the first call, then with the server's response token for subsequent calls. + /// + /// The GSS token received from the server, or null for the first call. + /// Cancellation token. + /// + /// A tuple of (outputToken, continueNeeded). + /// Send outputToken to the server. If continueNeeded is false, authentication is complete. + /// + public async Task InitSecurityContext( + ReadOnlyMemory? inputToken = null, + CancellationToken cancellation = default + ) + { + try + { + return await InitSecurityContextCore(inputToken, cancellation).ConfigureAwait(false); + } + catch + { + this.State = IAKerbInitiatorState.Failed; + throw; + } + } + + private IAKerbExchange currentExchange; + private Task pendingExchangeTask; + + private async Task InitSecurityContextCore( + ReadOnlyMemory? inputToken, + CancellationToken cancellation + ) + { + // Phase 1: Feed the server's response back to the transport + if (inputToken.HasValue) + { + var gssToken = GssApiToken.Decode(inputToken.Value); + var iakerb = new IAKerbContextToken(gssToken); + + this.cookie = iakerb.Header.Cookie; + + // Record in transcript (exact wire bytes) + var inputBytes = inputToken.Value.ToArray(); + this.transcript.Write(inputBytes, 0, inputBytes.Length); + + // Provide the KDC response to the transport (unblocks KerberosClient) + if (iakerb.Body.Length > 0) + { + this.currentExchange.ResponseSource.SetResult(iakerb.Body); + } + } + + // Phase 2: Start or advance the Kerberos flow + if (this.State == IAKerbInitiatorState.NotStarted) + { + this.authenticateTask = this.client.Authenticate(this.credential); + this.State = IAKerbInitiatorState.InProgress; + } + + // Phase 3: Wait for either the active task to complete or a new exchange + return await WaitForNextAction(cancellation).ConfigureAwait(false); + } + + private async Task WaitForNextAction( + CancellationToken cancellation + ) + { + // Only create a new exchange task when the previous one was consumed (set to null). + // A completed but unconsumed task holds a valid exchange result that must not be discarded. + if (this.pendingExchangeTask == null) + { + this.pendingExchangeTask = this.transport.GetNextExchangeAsync(cancellation); + } + + // Determine which task is currently active + Task activeTask = (Task)this.authenticateTask ?? this.serviceTicketTask; + + if (activeTask != null) + { + var winner = await Task.WhenAny(activeTask, this.pendingExchangeTask).ConfigureAwait(false); + + if (winner == activeTask) + { + if (activeTask == this.authenticateTask) + { + await this.authenticateTask.ConfigureAwait(false); // propagate exceptions + this.authenticateTask = null; + + // Start service ticket request + // IAKerb requires a subkey (for GSS_EXTS_FINISHED), which is generated + // when MutualRequired is set. Enforce this regardless of caller's ApOptions. + var apOptions = this.ApOptions | ApOptions.MutualRequired; + + this.serviceTicketTask = this.client.GetServiceTicket( + new RequestServiceTicket + { + ServicePrincipalName = this.servicePrincipalName, + ApOptions = apOptions, + GssContextFlags = this.GssContextFlags, + DelegationInfoModifier = this.InjectFinishedExtension + }, + cancellation + ); + + // Recurse - GetServiceTicket may produce a new exchange + // The existing pendingExchangeTask will capture it + return await WaitForNextAction(cancellation).ConfigureAwait(false); + } + + if (activeTask == this.serviceTicketTask) + { + this.SessionContext = await this.serviceTicketTask.ConfigureAwait(false); + this.State = IAKerbInitiatorState.Complete; + + var apReqBytes = GssApiToken.Encode(IAKerbOid, this.SessionContext.ApReq); + return new IAKerbContextResult(apReqBytes, continueNeeded: false, this.SessionContext); + } + } + } + + // An exchange is ready - wrap it in an IAKERB_PROXY token + this.currentExchange = await this.pendingExchangeTask.ConfigureAwait(false); + this.pendingExchangeTask = null; + + var header = new IAKerbHeader + { + TargetRealm = this.currentExchange.Domain, + Cookie = this.cookie + }; + + var outputToken = GssApiToken.EncodeIAKerbProxy(header, this.currentExchange.Request); + + // Record in transcript (exact wire bytes) + var outputBytes = outputToken.ToArray(); + this.transcript.Write(outputBytes, 0, outputBytes.Length); + + return new IAKerbContextResult(outputToken, continueNeeded: true); + } + + private void InjectFinishedExtension(DelegationInfo delegationInfo, KrbEncryptionKey subkey) + { + if (delegationInfo == null) + { + return; + } + + if (subkey == null) + { + throw new InvalidOperationException( + "IAKerb requires an authenticator subkey for GSS_EXTS_FINISHED, but none was generated."); + } + + // Compute the FINISHED checksum over the transcript of all preceding GSS tokens + var transcriptBytes = this.transcript.ToArray(); + var finished = KrbFinished.Create(transcriptBytes, subkey.AsKey()); + + // Encode as a GSS-API CFX extension + var finishedEncoded = finished.Encode(); + var extensionEncoded = GssApiCfxExtensions.Encode(KrbFinished.GssExtsFinishedType, finishedEncoded); + + delegationInfo.Extensions = extensionEncoded; + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.client?.Dispose(); + this.transcript?.Dispose(); + } + + this.disposed = true; + } + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/Kerberos.NET/Client/IAKerb/IAKerbTransport.cs b/Kerberos.NET/Client/IAKerb/IAKerbTransport.cs new file mode 100644 index 00000000..ae8e7c73 --- /dev/null +++ b/Kerberos.NET/Client/IAKerb/IAKerbTransport.cs @@ -0,0 +1,89 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Kerberos.NET.Transport; + +namespace Kerberos.NET.Client +{ + /// + /// Represents a single KDC message exchange captured by the IAKerb transport. + /// Contains the domain/realm, the outgoing request bytes, and a completion source + /// for the response that will be provided by the IAKerb initiator. + /// + internal class IAKerbExchange + { + public string Domain { get; } + + public ReadOnlyMemory Request { get; } + + public TaskCompletionSource> ResponseSource { get; } + + public IAKerbExchange(string domain, ReadOnlyMemory request) + { + this.Domain = domain; + this.Request = request; + this.ResponseSource = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously + ); + } + } + + /// + /// A custom Kerberos transport that bridges between the KerberosClient's + /// async message flow and the step-by-step IAKerb token exchange. + /// + /// When the KerberosClient calls SendMessage, this transport captures the + /// request and waits for the IAKerb initiator to provide a response. + /// Supports multiple sequential exchanges (pre-auth retries, referrals). + /// + internal class IAKerbTransport : KerberosTransportBase + { + private readonly ConcurrentQueue exchanges = new ConcurrentQueue(); + private readonly SemaphoreSlim exchangeReady = new SemaphoreSlim(0); + + public IAKerbTransport() + : base(null) + { + this.Enabled = true; + } + + public override async Task> SendMessage( + string domain, + ReadOnlyMemory req, + CancellationToken cancellation = default + ) + { + var exchange = new IAKerbExchange(domain, req); + + // Enqueue the exchange and signal the initiator + this.exchanges.Enqueue(exchange); + this.exchangeReady.Release(); + + // Wait for the IAKerb initiator to provide the KDC response + using var registration = cancellation.Register( + () => exchange.ResponseSource.TrySetCanceled(cancellation) + ); + + return await exchange.ResponseSource.Task.ConfigureAwait(false); + } + + /// + /// Gets the next pending exchange from the KerberosClient. + /// Called by the IAKerb initiator to retrieve outgoing KDC requests. + /// + internal async Task GetNextExchangeAsync(CancellationToken cancellation = default) + { + await this.exchangeReady.WaitAsync(cancellation).ConfigureAwait(false); + + this.exchanges.TryDequeue(out var exchange); + + return exchange; + } + } +} diff --git a/Kerberos.NET/Crypto/KeyUsage.cs b/Kerberos.NET/Crypto/KeyUsage.cs index 83a18d21..304aedb6 100644 --- a/Kerberos.NET/Crypto/KeyUsage.cs +++ b/Kerberos.NET/Crypto/KeyUsage.cs @@ -42,6 +42,8 @@ public enum KeyUsage SamEncTrackId = 26, PaServerReferral = 26, SamEncNonceSad = 27, + Finished = 41, + IAKerbFinished = 42, PaPkInitEx = 44, AsReq = 56, FastReqChecksum = 50, diff --git a/Kerberos.NET/Entities/GssApi/GssApiCfxExtensions.cs b/Kerberos.NET/Entities/GssApi/GssApiCfxExtensions.cs new file mode 100644 index 00000000..e90e462e --- /dev/null +++ b/Kerberos.NET/Entities/GssApi/GssApiCfxExtensions.cs @@ -0,0 +1,66 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using System.Buffers.Binary; + +namespace Kerberos.NET.Entities +{ + /// + /// Encodes and decodes GSS-API CFX extensions per RFC 6542. + /// Extensions are binary TLV: 4-byte big-endian type, 4-byte big-endian length, then data. + /// Multiple extensions are concatenated. + /// + public static class GssApiCfxExtensions + { + public static ReadOnlyMemory Encode(int extType, ReadOnlyMemory extData) + { + var result = new byte[4 + 4 + extData.Length]; + + BinaryPrimitives.WriteInt32BigEndian(result.AsSpan(0, 4), extType); + BinaryPrimitives.WriteInt32BigEndian(result.AsSpan(4, 4), extData.Length); + + extData.Span.CopyTo(result.AsSpan(8)); + + return result; + } + + public static (int type, ReadOnlyMemory data) Decode(ReadOnlyMemory encoded) + { + return FindExtension(encoded, extTypeFilter: null); + } + + /// + /// Searches the extensions blob for a specific extension type. + /// + public static (int type, ReadOnlyMemory data) FindExtension( + ReadOnlyMemory encoded, + int? extTypeFilter) + { + var span = encoded.Span; + int offset = 0; + + while (offset + 8 <= span.Length) + { + int extType = BinaryPrimitives.ReadInt32BigEndian(span.Slice(offset, 4)); + int extLen = BinaryPrimitives.ReadInt32BigEndian(span.Slice(offset + 4, 4)); + + if (offset + 8 + extLen > span.Length) + { + throw new InvalidOperationException("GSS-API CFX extension length exceeds available data."); + } + + if (!extTypeFilter.HasValue || extType == extTypeFilter.Value) + { + return (extType, encoded.Slice(offset + 8, extLen)); + } + + offset += 8 + extLen; + } + + throw new InvalidOperationException($"GSS-API CFX extension type {extTypeFilter} not found."); + } + } +} diff --git a/Kerberos.NET/Entities/GssApi/GssApiToken.cs b/Kerberos.NET/Entities/GssApi/GssApiToken.cs index 5ac97f5e..0e39085b 100644 --- a/Kerberos.NET/Entities/GssApi/GssApiToken.cs +++ b/Kerberos.NET/Entities/GssApi/GssApiToken.cs @@ -51,6 +51,87 @@ private static readonly ReadOnlyDictionary MessageTokenTypes private static readonly ReadOnlyDictionary TokenMessageTypes = new(MessageTokenTypes.ToDictionary(t => t.Value, t => t.Key)); + private static readonly Oid IAKerbOid = new(MechType.IAKerb); + + public static ReadOnlyMemory EncodeIAKerbProxy(IAKerbHeader header, ReadOnlyMemory kerbMessage) + { + if (header == null) + { + throw new ArgumentNullException(nameof(header)); + } + + // Encode the OID using an AsnWriter + byte[] oidEncoded; + + using (var oidWriter = new AsnWriter(AsnEncodingRules.DER)) + { + oidWriter.WriteObjectIdentifier(IAKerbOid); + oidEncoded = oidWriter.Encode(); + } + + // TOK_ID for IAKERB_PROXY: 05 01 + var tokenTypeBytes = new byte[] { 0x05, 0x01 }; + var headerEncoded = header.Encode(); + + // Calculate total inner content length + int innerLength = oidEncoded.Length + tokenTypeBytes.Length + headerEncoded.Length + kerbMessage.Length; + + // Build the APPLICATION 0 IMPLICIT SEQUENCE manually + // Tag = 0x60, then DER length, then content + using (var stream = new System.IO.MemoryStream()) + { + stream.WriteByte(0x60); // APPLICATION 0 CONSTRUCTED + WriteDerLength(stream, innerLength); + stream.Write(oidEncoded, 0, oidEncoded.Length); + stream.Write(tokenTypeBytes, 0, tokenTypeBytes.Length); + + var headerBytes = headerEncoded.ToArray(); + stream.Write(headerBytes, 0, headerBytes.Length); + + if (kerbMessage.Length > 0) + { + var msgBytes = kerbMessage.ToArray(); + stream.Write(msgBytes, 0, msgBytes.Length); + } + + return stream.ToArray(); + } + } + + private static void WriteDerLength(System.IO.MemoryStream stream, int length) + { + if (length < 0x80) + { + stream.WriteByte((byte)length); + } + else if (length <= 0xFF) + { + stream.WriteByte(0x81); + stream.WriteByte((byte)length); + } + else if (length <= 0xFFFF) + { + stream.WriteByte(0x82); + stream.WriteByte((byte)(length >> 8)); + stream.WriteByte((byte)length); + } + else if (length <= 0xFFFFFF) + { + stream.WriteByte(0x83); + stream.WriteByte((byte)(length >> 16)); + stream.WriteByte((byte)(length >> 8)); + stream.WriteByte((byte)length); + } + else + { + stream.WriteByte(0x84); + stream.WriteByte((byte)(length >> 24)); + stream.WriteByte((byte)(length >> 16)); + stream.WriteByte((byte)(length >> 8)); + stream.WriteByte((byte)length); + } + } + public static ReadOnlyMemory Encode(Oid oid, NegotiationToken token) { if (token == null) diff --git a/Kerberos.NET/Entities/Krb/DelegationInfo.cs b/Kerberos.NET/Entities/Krb/DelegationInfo.cs index 7dbc0d11..b9216551 100644 --- a/Kerberos.NET/Entities/Krb/DelegationInfo.cs +++ b/Kerberos.NET/Entities/Krb/DelegationInfo.cs @@ -80,6 +80,11 @@ public ReadOnlyMemory Encode() writer.Write(deleg.ToArray()); } + if (this.Extensions.Length > 0) + { + writer.Write(this.Extensions.ToArray()); + } + return stream.ToArray(); } } @@ -94,28 +99,31 @@ public DelegationInfo Decode(ReadOnlyMemory value) this.Flags = (GssContextEstablishmentFlag)reader.ReadBytes(4).AsLong(littleEndian: true); - if (reader.BytesAvailable() > 0) + if (this.Flags.HasFlag(GssContextEstablishmentFlag.GSS_C_DELEG_FLAG)) { - this.DelegationOption = reader.ReadInt16(); - } + if (reader.BytesAvailable() > 0) + { + this.DelegationOption = reader.ReadInt16(); + } - int delegationLength = 0; + int delegationLength = 0; - if (reader.BytesAvailable() > 0) - { - delegationLength = reader.ReadInt16(); - } + if (reader.BytesAvailable() > 0) + { + delegationLength = reader.ReadInt16(); + } - byte[] delegationTicket = null; + byte[] delegationTicket = null; - if (reader.BytesAvailable() > 0) - { - delegationTicket = reader.ReadBytes(delegationLength); - } + if (reader.BytesAvailable() > 0) + { + delegationTicket = reader.ReadBytes(delegationLength); + } - if (delegationTicket != null && delegationTicket.Length > 0) - { - this.DelegationTicket = KrbCred.DecodeApplication(delegationTicket); + if (delegationTicket != null && delegationTicket.Length > 0) + { + this.DelegationTicket = KrbCred.DecodeApplication(delegationTicket); + } } if (reader.BytesAvailable() > 0) diff --git a/Kerberos.NET/Entities/Krb/KerberosErrorCode.cs b/Kerberos.NET/Entities/Krb/KerberosErrorCode.cs index 234f2cee..209e836c 100644 --- a/Kerberos.NET/Entities/Krb/KerberosErrorCode.cs +++ b/Kerberos.NET/Entities/Krb/KerberosErrorCode.cs @@ -387,6 +387,16 @@ public enum KerberosErrorCode /// KRB_AP_ERR_PRINCIPAL_RESERVED = 84, + /// + /// The IAKERB proxy could not find a KDC. + /// + KRB_AP_ERR_IAKERB_KDC_NOT_FOUND = 85, + + /// + /// The KDC did not respond to the IAKERB proxy. + /// + KRB_AP_ERR_IAKERB_KDC_NO_RESPONSE = 86, + /// /// The provided pre-auth data has expired. /// diff --git a/Kerberos.NET/Entities/Krb/KrbApReq.cs b/Kerberos.NET/Entities/Krb/KrbApReq.cs index 75b07f5d..8285cf8d 100644 --- a/Kerberos.NET/Entities/Krb/KrbApReq.cs +++ b/Kerberos.NET/Entities/Krb/KrbApReq.cs @@ -59,6 +59,16 @@ out KrbAuthenticator authenticator CRealm = tgsRep.CRealm }; + if (rst.IncludeSequenceNumber ?? true) + { + authenticator.SequenceNumber = GetNonce(); + } + + if (rst.ApOptions.HasFlag(ApOptions.MutualRequired)) + { + authenticator.Subkey = KrbEncryptionKey.Generate(authenticatorKey.EncryptionType); + } + if (rst.AuthenticatorChecksum != null) { authenticator.Checksum = rst.AuthenticatorChecksum; @@ -73,17 +83,11 @@ out KrbAuthenticator authenticator } else if (rst.GssContextFlags != GssContextEstablishmentFlag.GSS_C_NONE) { - authenticator.Checksum = KrbChecksum.EncodeDelegationChecksum(new DelegationInfo(rst)); - } + var delegationInfo = new DelegationInfo(rst); - if (rst.IncludeSequenceNumber ?? true) - { - authenticator.SequenceNumber = GetNonce(); - } + rst.DelegationInfoModifier?.Invoke(delegationInfo, authenticator.Subkey); - if (rst.ApOptions.HasFlag(ApOptions.MutualRequired)) - { - authenticator.Subkey = KrbEncryptionKey.Generate(authenticatorKey.EncryptionType); + authenticator.Checksum = KrbChecksum.EncodeDelegationChecksum(delegationInfo); } Now(out DateTimeOffset ctime, out int usec); diff --git a/Kerberos.NET/Entities/Krb/KrbFinished.cs b/Kerberos.NET/Entities/Krb/KrbFinished.cs new file mode 100644 index 00000000..24265273 --- /dev/null +++ b/Kerberos.NET/Entities/Krb/KrbFinished.cs @@ -0,0 +1,107 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using System.Security.Cryptography.Asn1; +using Kerberos.NET.Crypto; + +namespace Kerberos.NET.Entities +{ + /// + /// KRB-FINISHED structure for IAKerb GSS_EXTS_FINISHED extension. + /// Contains a checksum of all preceding GSS-API context tokens. + /// + public class KrbFinished + { + /* + KRB-FINISHED ::= SEQUENCE { + gss-mic [1] Checksum, + } + */ + + public const int GssExtsFinishedType = 2; + + public KrbChecksum GssMic { get; set; } + + public ReadOnlyMemory Encode() + { + var writer = new AsnWriter(AsnEncodingRules.DER); + + writer.PushSequence(); + + writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 1)); + GssMic.Encode(writer); + writer.PopSequence(new Asn1Tag(TagClass.ContextSpecific, 1)); + + writer.PopSequence(); + + return writer.EncodeAsMemory(); + } + + public static KrbFinished Decode(ReadOnlyMemory data) + { + var reader = new AsnReader(data, AsnEncodingRules.DER); + var sequenceReader = reader.ReadSequence(); + + var explicitReader = sequenceReader.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1)); + + KrbChecksum.Decode(explicitReader, out KrbChecksum checksum); + + explicitReader.ThrowIfNotEmpty(); + sequenceReader.ThrowIfNotEmpty(); + + return new KrbFinished { GssMic = checksum }; + } + + /// + /// Creates a KrbFinished with a checksum computed over the concatenated GSS-API tokens. + /// + public static KrbFinished Create(ReadOnlyMemory transcript, KerberosKey subkey) + { + if (subkey == null) + { + throw new ArgumentNullException(nameof(subkey)); + } + + var checksumType = CryptoService.ConvertType(subkey.EncryptionType); + + var checksum = CryptoService.CreateChecksum(checksumType, signatureData: transcript); + checksum.Usage = KeyUsage.Finished; + checksum.Sign(subkey); + + return new KrbFinished + { + GssMic = new KrbChecksum + { + Checksum = checksum.Signature, + Type = checksumType + } + }; + } + + /// + /// Verifies the KRB-FINISHED checksum against the transcript of exchanged tokens. + /// Throws if verification fails. + /// + public void Verify(ReadOnlyMemory transcript, KerberosKey subkey) + { + if (subkey == null) + { + throw new ArgumentNullException(nameof(subkey)); + } + + var checksumType = this.GssMic.Type; + + var checksum = CryptoService.CreateChecksum( + checksumType, + signature: this.GssMic.Checksum, + signatureData: transcript + ); + checksum.Usage = KeyUsage.Finished; + + checksum.Validate(subkey); + } + } +} diff --git a/Kerberos.NET/Entities/RequestServiceTicket.cs b/Kerberos.NET/Entities/RequestServiceTicket.cs index d5e78167..3bbbfbbb 100644 --- a/Kerberos.NET/Entities/RequestServiceTicket.cs +++ b/Kerberos.NET/Entities/RequestServiceTicket.cs @@ -91,6 +91,13 @@ public struct RequestServiceTicket : IEquatable /// public KrbChecksum AuthenticatorChecksum { get; set; } + /// + /// Optional callback invoked during AP-REQ creation after the subkey is generated. + /// Used by IAKerb to inject GSS_EXTS_FINISHED extension data into the delegation info. + /// The callback receives the generated subkey and the DelegationInfo to modify. + /// + public Action DelegationInfoModifier { get; set; } + /// /// Indicates whether the client should attempt to use tickets that are already expired. /// diff --git a/Kerberos.NET/Server/IAKerbAcceptor.cs b/Kerberos.NET/Server/IAKerbAcceptor.cs new file mode 100644 index 00000000..e2b8f8f7 --- /dev/null +++ b/Kerberos.NET/Server/IAKerbAcceptor.cs @@ -0,0 +1,338 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Kerberos.NET.Crypto; +using Kerberos.NET.Entities; +using Kerberos.NET.Transport; +using Microsoft.Extensions.Logging; + +namespace Kerberos.NET.Server +{ + /// + /// The current state of the IAKerb acceptor context. + /// + public enum IAKerbAcceptorState + { + /// + /// Waiting for the first IAKerb token. + /// + WaitingForToken, + + /// + /// Proxying KDC messages. + /// + Proxying, + + /// + /// Context establishment is complete. + /// + Complete, + + /// + /// Context establishment failed. + /// + Failed + } + + /// + /// IAKerb server-side acceptor/proxy. Receives IAKERB_PROXY tokens from the client, + /// forwards the contained Kerberos messages to the KDC, and wraps responses back. + /// When the client sends the final AP-REQ, validates the GSS_EXTS_FINISHED checksum + /// and completes authentication. + /// + /// Usage: + /// + /// var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys); + /// while (true) + /// { + /// var clientToken = ReceiveFromClient(); // application-specific + /// var result = await acceptor.AcceptSecurityContext(clientToken); + /// if (result.IsComplete) + /// { + /// // Authentication complete - result.DecryptedApReq has the identity + /// if (result.Token.HasValue) SendToClient(result.Token.Value); // AP-REP + /// break; + /// } + /// SendToClient(result.Token.Value); // proxy response + /// } + /// + /// + public class IAKerbAcceptor : IDisposable + { + private static readonly Oid IAKerbOid = new(MechType.IAKerb); + + private readonly IKerberosTransport kdcTransport; + private readonly KeyTable serviceKeys; + private readonly MemoryStream transcript = new(); + private readonly ILogger logger; + private bool disposed; + + /// + /// The current state of the acceptor. + /// + public IAKerbAcceptorState State { get; private set; } = IAKerbAcceptorState.WaitingForToken; + + /// + /// The decrypted AP-REQ after successful authentication. + /// + public DecryptedKrbApReq DecryptedApReq { get; private set; } + + /// + /// Creates a new IAKerb acceptor that uses the given transport to forward messages to the KDC. + /// + /// Transport to communicate with the real KDC. + /// The service's keytab for decrypting the final AP-REQ. + /// Optional logger factory. + public IAKerbAcceptor( + IKerberosTransport kdcTransport, + KeyTable serviceKeys, + ILoggerFactory logger = null + ) + { + this.kdcTransport = kdcTransport ?? throw new ArgumentNullException(nameof(kdcTransport)); + this.serviceKeys = serviceKeys ?? throw new ArgumentNullException(nameof(serviceKeys)); + this.logger = logger.CreateLoggerSafe(); + } + + /// + /// Processes an incoming GSS token from the IAKerb initiator. + /// + /// The GSS token from the client. + /// Cancellation token. + /// + /// An describing the next action. + /// If is false, send + /// to the client and continue. If true, authentication succeeded and + /// contains the authenticated identity. + /// + public async Task AcceptSecurityContext( + ReadOnlyMemory inputToken, + CancellationToken cancellation = default + ) + { + try + { + return await AcceptSecurityContextCore(inputToken, cancellation).ConfigureAwait(false); + } + catch + { + this.State = IAKerbAcceptorState.Failed; + throw; + } + } + + private async Task AcceptSecurityContextCore( + ReadOnlyMemory inputToken, + CancellationToken cancellation + ) + { + var gssToken = GssApiToken.Decode(inputToken); + + if (gssToken.ThisMech.Value == MechType.IAKerb && + gssToken.MessageType == MessageType.IAKERB_HEADER) + { + return await HandleIAKerbProxy(inputToken, gssToken, cancellation).ConfigureAwait(false); + } + + if (gssToken.ThisMech.Value == MechType.IAKerb && + gssToken.MessageType == MessageType.KRB_AP_REQ) + { + return HandleApReq(inputToken, gssToken); + } + + throw new KerberosProtocolException( + KerberosErrorCode.KRB_ERR_GENERIC, + $"Unexpected IAKerb token type: mech={gssToken.ThisMech.Value}, msgType={gssToken.MessageType}" + ); + } + + private async Task HandleIAKerbProxy( + ReadOnlyMemory inputToken, + GssApiToken gssToken, + CancellationToken cancellation + ) + { + var iakerb = new IAKerbContextToken(gssToken); + var targetRealm = iakerb.Header.TargetRealm; + + this.State = IAKerbAcceptorState.Proxying; + + // Record input token in transcript + this.transcript.Write(inputToken.ToArray(), 0, inputToken.Length); + + if (iakerb.Body.Length == 0) + { + // Empty body = realm query (Section 3.1) + var realmHeader = new IAKerbHeader { TargetRealm = targetRealm }; + var realmResponse = GssApiToken.EncodeIAKerbProxy(realmHeader, ReadOnlyMemory.Empty); + this.transcript.Write(realmResponse.ToArray(), 0, realmResponse.Length); + return new IAKerbAcceptorResult(realmResponse, isComplete: false); + } + + // Forward the Kerberos message to the KDC using raw transport + ReadOnlyMemory kdcResponse; + + try + { + if (this.kdcTransport is IKerberosTransport2 transport2) + { + kdcResponse = await transport2.SendMessage( + targetRealm, + iakerb.Body, + cancellation + ).ConfigureAwait(false); + } + else + { + // Fall back to typed send - try AS-REP first, then TGS-REP + kdcResponse = await SendRawMessage(targetRealm, iakerb.Body, cancellation).ConfigureAwait(false); + } + } + catch (KerberosTransportException ex) when (ex.Error == null) + { + // Could not reach KDC - generate IAKerb-specific error + this.logger.LogWarning(ex, "IAKerb proxy could not reach KDC for realm {Realm}", targetRealm); + + var error = new KrbError + { + ErrorCode = KerberosErrorCode.KRB_AP_ERR_IAKERB_KDC_NO_RESPONSE, + EText = "The KDC did not respond to the IAKERB proxy", + Realm = targetRealm, + SName = KrbPrincipalName.FromString($"krbtgt/{targetRealm}") + }; + + kdcResponse = error.EncodeApplication(); + } + + // Wrap the KDC response in IAKERB_PROXY + var responseHeader = new IAKerbHeader + { + TargetRealm = targetRealm + }; + + var responseToken = GssApiToken.EncodeIAKerbProxy(responseHeader, kdcResponse); + + // Record response token in transcript + this.transcript.Write(responseToken.ToArray(), 0, responseToken.Length); + + return new IAKerbAcceptorResult(responseToken, isComplete: false); + } + + private async Task> SendRawMessage( + string realm, + ReadOnlyMemory message, + CancellationToken cancellation + ) + { + // Use IKerberosTransport2 if available for raw byte transport + if (this.kdcTransport is IKerberosTransport2 transport2) + { + return await transport2.SendMessage(realm, message, cancellation).ConfigureAwait(false); + } + + throw new KerberosProtocolException( + KerberosErrorCode.KRB_AP_ERR_IAKERB_KDC_NOT_FOUND, + "Transport does not support raw message forwarding" + ); + } + + private IAKerbAcceptorResult HandleApReq( + ReadOnlyMemory inputToken, + GssApiToken gssToken + ) + { + // This is the final AP-REQ - authenticate it + var apReq = KrbApReq.DecodeApplication(gssToken.Token); + + var decryptedApReq = new DecryptedKrbApReq(apReq); + decryptedApReq.Decrypt(this.serviceKeys); + decryptedApReq.Validate(ValidationActions.All); + + // Validate GSS_EXTS_FINISHED + ValidateFinishedChecksum(decryptedApReq); + + this.DecryptedApReq = decryptedApReq; + this.State = IAKerbAcceptorState.Complete; + + // Generate AP-REP for mutual authentication if requested + ReadOnlyMemory? apRepToken = null; + + if (apReq.ApOptions.HasFlag(ApOptions.MutualRequired)) + { + var apRep = decryptedApReq.CreateResponseMessage(); + apRepToken = apRep.EncodeApplication(); + } + + return new IAKerbAcceptorResult(apRepToken, isComplete: true, decryptedApReq); + } + + private void ValidateFinishedChecksum(DecryptedKrbApReq decryptedApReq) + { + var authenticator = decryptedApReq.Authenticator; + + if (authenticator.Subkey == null) + { + throw new KerberosValidationException( + "IAKerb AP-REQ must contain an authenticator subkey" + ); + } + + if (authenticator.Checksum == null || + authenticator.Checksum.Type != KrbChecksum.ChecksumContainsDelegationType) + { + throw new KerberosValidationException( + "IAKerb AP-REQ must contain a delegation-type checksum with GSS_EXTS_FINISHED" + ); + } + + // Decode delegation info to get extensions + var delegationInfo = authenticator.Checksum.DecodeDelegation(); + + if (delegationInfo.Extensions.Length == 0) + { + throw new KerberosValidationException( + "IAKerb AP-REQ must contain GSS_EXTS_FINISHED extension" + ); + } + + // Decode the GSS extension - search for GSS_EXTS_FINISHED specifically + var (extType, extData) = GssApiCfxExtensions.FindExtension( + delegationInfo.Extensions, + KrbFinished.GssExtsFinishedType + ); + + // Decode and verify the FINISHED checksum + var finished = KrbFinished.Decode(extData); + var transcriptBytes = this.transcript.ToArray(); + var subkey = authenticator.Subkey.AsKey(); + + finished.Verify(transcriptBytes, subkey); + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.transcript?.Dispose(); + } + + this.disposed = true; + } + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/Kerberos.NET/Server/IAKerbAcceptorResult.cs b/Kerberos.NET/Server/IAKerbAcceptorResult.cs new file mode 100644 index 00000000..ea412021 --- /dev/null +++ b/Kerberos.NET/Server/IAKerbAcceptorResult.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using System; +using Kerberos.NET.Crypto; + +namespace Kerberos.NET.Server +{ + /// + /// The result of a single step of IAKerb context acceptance. + /// + public class IAKerbAcceptorResult + { + /// + /// The GSS token to send back to the initiator. + /// Present during the proxy phase (when is false). + /// May also be present when complete if mutual authentication was requested (AP-REP). + /// + public ReadOnlyMemory? Token { get; } + + /// + /// Whether context establishment is complete. + /// When false, send to the initiator and continue the exchange. + /// When true, the client has been authenticated. + /// + public bool IsComplete { get; } + + /// + /// The decrypted AP-REQ from the authenticated client. + /// Only available when is true. + /// + public DecryptedKrbApReq DecryptedApReq { get; } + + internal IAKerbAcceptorResult(ReadOnlyMemory? token, bool isComplete, DecryptedKrbApReq decryptedApReq = null) + { + this.Token = token; + this.IsComplete = isComplete; + this.DecryptedApReq = decryptedApReq; + } + } +} diff --git a/Tests/Tests.Kerberos.NET/End2End/IAKerbTests.cs b/Tests/Tests.Kerberos.NET/End2End/IAKerbTests.cs new file mode 100644 index 00000000..d8021112 --- /dev/null +++ b/Tests/Tests.Kerberos.NET/End2End/IAKerbTests.cs @@ -0,0 +1,240 @@ +// ----------------------------------------------------------------------- +// Licensed to The .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ----------------------------------------------------------------------- + +using Kerberos.NET; +using Kerberos.NET.Client; +using Kerberos.NET.Credentials; +using Kerberos.NET.Crypto; +using Kerberos.NET.Entities; +using Kerberos.NET.Server; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading.Tasks; +using static Tests.Kerberos.NET.KdcListener; + +namespace Tests.Kerberos.NET +{ + [TestClass] + public class IAKerbTests : KdcListenerTestBase + { + private static KeyTable CreateServiceKeyTable() + { + return new KeyTable( + new KerberosKey( + FakeAdminAtCorpPassword, + principalName: new PrincipalName( + PrincipalNameType.NT_PRINCIPAL, + "CORP.IDENTITYINTERVENTION.com", + new[] { FakeAppServiceSpn } + ), + saltType: SaltType.ActiveDirectoryUser + ) + ); + } + + [TestMethod] + public async Task IAKerb_EndToEnd_PasswordCredential() + { + var port = NextPort(); + + using (var listener = StartListener(port)) + { + var credential = new KerberosPasswordCredential(AdminAtCorpUserName, FakeAdminAtCorpPassword); + + using (var initiator = new IAKerbInitiator(credential, FakeAppServiceSpn)) + { + var kdcTransport = new InMemoryTransport(listener); + var serviceKeys = CreateServiceKeyTable(); + + using (var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys)) + { + var result = await initiator.InitSecurityContext(); + + Assert.IsTrue(result.ContinueNeeded, "First call should require continuation"); + Assert.IsTrue(result.Token.Length > 0, "First token should not be empty"); + + while (true) + { + var serverResult = await acceptor.AcceptSecurityContext(result.Token); + + if (serverResult.IsComplete) + { + Assert.AreEqual(IAKerbAcceptorState.Complete, acceptor.State); + Assert.IsNotNull(serverResult.DecryptedApReq); + break; + } + + Assert.IsTrue(serverResult.Token.HasValue, "Server should return a token during proxy phase"); + + result = await initiator.InitSecurityContext(serverResult.Token.Value); + } + + Assert.AreEqual(IAKerbInitiatorState.Complete, initiator.State); + Assert.AreEqual(IAKerbAcceptorState.Complete, acceptor.State); + Assert.IsNotNull(initiator.SessionContext); + Assert.IsNotNull(acceptor.DecryptedApReq); + } + } + } + } + + [TestMethod] + public async Task IAKerb_EndToEnd_ValidatesFinishedChecksum() + { + var port = NextPort(); + + using (var listener = StartListener(port)) + { + var credential = new KerberosPasswordCredential(AdminAtCorpUserName, FakeAdminAtCorpPassword); + + using (var initiator = new IAKerbInitiator(credential, FakeAppServiceSpn)) + { + var kdcTransport = new InMemoryTransport(listener); + var serviceKeys = CreateServiceKeyTable(); + + using (var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys)) + { + var result = await initiator.InitSecurityContext(); + + while (true) + { + var serverResult = await acceptor.AcceptSecurityContext(result.Token); + + if (serverResult.IsComplete) + { + break; + } + + result = await initiator.InitSecurityContext(serverResult.Token.Value); + } + + // If we get here without exception, the FINISHED checksum was valid + Assert.AreEqual(IAKerbAcceptorState.Complete, acceptor.State); + } + } + } + } + + [TestMethod] + public async Task IAKerb_EndToEnd_MultipleRoundTrips() + { + // This test verifies that the IAKerb exchange handles the multiple + // round trips required for pre-authentication (AS exchange) and TGS exchange + var port = NextPort(); + + using (var listener = StartListener(port)) + { + var credential = new KerberosPasswordCredential(AdminAtCorpUserName, FakeAdminAtCorpPassword); + + using (var initiator = new IAKerbInitiator(credential, FakeAppServiceSpn)) + { + var kdcTransport = new InMemoryTransport(listener); + var serviceKeys = CreateServiceKeyTable(); + + using (var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys)) + { + int roundTrips = 0; + var result = await initiator.InitSecurityContext(); + + while (true) + { + roundTrips++; + var serverResult = await acceptor.AcceptSecurityContext(result.Token); + + if (serverResult.IsComplete) + { + break; + } + + result = await initiator.InitSecurityContext(serverResult.Token.Value); + } + + // Pre-auth typically requires at least 2 round trips: + // 1. AS-REQ -> KRB-ERROR (pre-auth required) + // 2. AS-REQ (with pre-auth) -> AS-REP + // Plus 1 for TGS-REQ -> TGS-REP + // Plus 1 for the final AP-REQ + Assert.IsTrue(roundTrips >= 2, $"Expected at least 2 round trips, got {roundTrips}"); + Assert.AreEqual(IAKerbInitiatorState.Complete, initiator.State); + } + } + } + } + + [TestMethod] + public async Task IAKerb_InitiatorState_Transitions() + { + var port = NextPort(); + + using (var listener = StartListener(port)) + { + var credential = new KerberosPasswordCredential(AdminAtCorpUserName, FakeAdminAtCorpPassword); + + using (var initiator = new IAKerbInitiator(credential, FakeAppServiceSpn)) + { + Assert.AreEqual(IAKerbInitiatorState.NotStarted, initiator.State); + + var kdcTransport = new InMemoryTransport(listener); + var serviceKeys = CreateServiceKeyTable(); + + using (var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys)) + { + var result = await initiator.InitSecurityContext(); + Assert.AreEqual(IAKerbInitiatorState.InProgress, initiator.State); + + while (true) + { + var serverResult = await acceptor.AcceptSecurityContext(result.Token); + + if (serverResult.IsComplete) + { + break; + } + + result = await initiator.InitSecurityContext(serverResult.Token.Value); + } + + Assert.AreEqual(IAKerbInitiatorState.Complete, initiator.State); + } + } + } + } + + [TestMethod] + public async Task IAKerb_AcceptorState_Transitions() + { + var port = NextPort(); + + using (var listener = StartListener(port)) + { + var credential = new KerberosPasswordCredential(AdminAtCorpUserName, FakeAdminAtCorpPassword); + + using (var initiator = new IAKerbInitiator(credential, FakeAppServiceSpn)) + { + var kdcTransport = new InMemoryTransport(listener); + var serviceKeys = CreateServiceKeyTable(); + + using (var acceptor = new IAKerbAcceptor(kdcTransport, serviceKeys)) + { + Assert.AreEqual(IAKerbAcceptorState.WaitingForToken, acceptor.State); + + var result = await initiator.InitSecurityContext(); + var serverResult = await acceptor.AcceptSecurityContext(result.Token); + + Assert.AreEqual(IAKerbAcceptorState.Proxying, acceptor.State); + + while (!serverResult.IsComplete) + { + result = await initiator.InitSecurityContext(serverResult.Token.Value); + serverResult = await acceptor.AcceptSecurityContext(result.Token); + } + + Assert.AreEqual(IAKerbAcceptorState.Complete, acceptor.State); + } + } + } + } + } +}