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);
+ }
+ }
+ }
+ }
+ }
+}