From b0eb32a321df2e642eac9fc15e4dad30491efbac Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 10:57:50 -0700 Subject: [PATCH 01/11] Add test coverage of KdcHandler cname/crealm --- .../Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index 40dcfd2a..2cec17db 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -26,11 +26,33 @@ public class KdcHandlerTests : BaseTest [TestMethod] public void KdcAsReqHandler_Sync() { - KrbAsRep asRep = RequestTgt(out _); + KrbAsRep asRep = RequestTgt(out _, out KrbAsReq asReq); Assert.IsNotNull(asRep); + + // RFC 4120 Section 3.1.5 Receipt of KRB_AS_REP Message + // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields in + // the cleartext portion of the reply match what it requested." + Assert.AreEqual(Realm, asReq.Body.Realm); Assert.AreEqual(Realm, asRep.CRealm); + + Assert.AreEqual(Upn, asReq.Body.CName.FullyQualifiedName); Assert.AreEqual(Upn, asRep.CName.FullyQualifiedName); + + // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside + var realmService = new FakeRealmService(Realm); + var tgtPrincipalName = KrbPrincipalName.WellKnown.Krbtgt(Realm); + var tgtEncPartKey = realmService.Principals.Find(tgtPrincipalName).RetrieveLongTermCredential(); + + var ticketEncPart = asRep.Ticket.EncryptedPart.Decrypt( + tgtEncPartKey, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(ticketEncPart); + Assert.AreEqual(Realm, ticketEncPart.CRealm); + Assert.AreEqual(Upn, ticketEncPart.CName.FullyQualifiedName); } [TestMethod] @@ -40,11 +62,13 @@ public void KdcTgsReqHandler_Sync() Assert.IsNotNull(asRep); + var spn = "host/foo." + Realm; + var tgsReq = KrbTgsReq.CreateTgsReq( new RequestServiceTicket { Realm = Realm, - ServicePrincipalName = "host/foo." + Realm + ServicePrincipalName = spn }, tgtKey, asRep, @@ -71,9 +95,31 @@ out KrbEncryptionKey sessionKey ); Assert.IsNotNull(encKdcRepPart); + + Assert.AreEqual(Realm, tgsRep.CRealm); + Assert.AreEqual(Upn, tgsRep.CName.FullyQualifiedName); + + // Clients can't decrypt service tickets usually, but for the sake of testing let's check what's inside + var realmService = new FakeRealmService(Realm); + var ticketEncPart = realmService.Principals.Find(KrbPrincipalName.FromString(spn)).RetrieveLongTermCredential(); + + var serviceTicketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt( + ticketEncPart, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(serviceTicketEncPart); + Assert.AreEqual(Realm, serviceTicketEncPart.CRealm); + Assert.AreEqual(Upn, serviceTicketEncPart.CName.FullyQualifiedName); + } + + private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey) + { + return RequestTgt(out sessionKey, out _); } - private static KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey) + private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey, out KrbAsReq asReq) { var cred = new KerberosPasswordCredential(Upn, "P@ssw0rd!") { @@ -89,7 +135,7 @@ private static KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey) Configuration = Krb5Config.Default() }; - var asReq = KrbAsReq.CreateAsReq( + asReq = KrbAsReq.CreateAsReq( cred, AuthenticationOptions.AllAuthentication ); From c2909b1deee00b7baedf7ba33723e3f26e4034a1 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 12:39:14 -0700 Subject: [PATCH 02/11] Add failing test for referral TGT --- .../Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index 2cec17db..88be232b 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -3,6 +3,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // ----------------------------------------------------------------------- +using System; using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using Kerberos.NET; @@ -23,6 +24,10 @@ public class KdcHandlerTests : BaseTest private const string Realm = "CORP2.IDENTITYINTERVENTION.COM"; private const string Upn = "fake@" + Realm; + private const string Realm2 = "TEST.COM"; + private const string Upn2WithoutRealm = "fakeuser"; + // private const string Upn2WithoutRealm = "fakeuser@" + Realm2; + [TestMethod] public void KdcAsReqHandler_Sync() { @@ -114,6 +119,178 @@ out KrbEncryptionKey sessionKey Assert.AreEqual(Upn, serviceTicketEncPart.CName.FullyQualifiedName); } + [TestMethod] + public void KdcTgsReqHandler_Sync_ReferralTgt() + { + var sourceRealm = Realm2; + var destRealm = Realm; + var cname = new KrbPrincipalName + { + Type = PrincipalNameType.NT_PRINCIPAL, + Name = new[] { Upn2WithoutRealm } + }; + + KrbAsRep asRep = CreateReferralTgt(sourceRealm, destRealm, cname, out KerberosKey tgtKey, out KerberosKey asRepKey, out KrbEncryptionKey sessionKey); + + // Check the TGT we just generated + Assert.IsNotNull(asRep); + Assert.AreEqual(sourceRealm, asRep.CRealm); + Assert.AreEqual(Upn2WithoutRealm, asRep.CName.FullyQualifiedName); + + // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside + var tgtEncPart = asRep.Ticket.EncryptedPart.Decrypt( + tgtKey, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(tgtEncPart); + Assert.AreEqual(sourceRealm, tgtEncPart.CRealm); + Assert.AreEqual(Upn2WithoutRealm, tgtEncPart.CName.FullyQualifiedName); + + // Send a TGS-REQ to get a service ticket in the destination realm + var spn = "host/foo." + Realm; + + var tgsReq = KrbTgsReq.CreateTgsReq( + new RequestServiceTicket + { + Realm = Realm, + ServicePrincipalName = spn + }, + sessionKey, + asRep, + out KrbEncryptionKey subSessionKey + ); + + var handler = new KdcTgsReqMessageHandler(tgsReq.EncodeApplication(), new KdcServerOptions + { + DefaultRealm = destRealm, + IsDebug = true, + RealmLocator = realm => new FakeRealmService(realm) + }); + + var results = handler.Execute(); + + var tgsRep = KrbTgsRep.DecodeApplication(results); + + Assert.IsNotNull(tgsRep); + + var encKdcRepPart = tgsRep.EncPart.Decrypt( + subSessionKey.AsKey(), + KeyUsage.EncTgsRepPartSubSessionKey, + d => KrbEncTgsRepPart.DecodeApplication(d) + ); + + Assert.IsNotNull(encKdcRepPart); + + Assert.AreEqual(sourceRealm, tgsRep.CRealm); + Assert.AreEqual(Upn2WithoutRealm, tgsRep.CName.FullyQualifiedName); + + // Clients can't decrypt service tickets usually, but for the sake of testing let's check what's inside + var destRealmService = new FakeRealmService(destRealm); + var servicePrincipal = destRealmService.Principals.Find(KrbPrincipalName.FromString(spn), destRealm); + var servicePrincipalKey = servicePrincipal.RetrieveLongTermCredential(); + + var ticketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt( + servicePrincipalKey, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(ticketEncPart); + Assert.AreEqual(sourceRealm, ticketEncPart.CRealm); + Assert.AreEqual(Upn2WithoutRealm, ticketEncPart.CName.FullyQualifiedName); + } + + private KrbAsRep CreateReferralTgt(string sourceRealm, string destRealm, KrbPrincipalName cname, out KerberosKey tgtKey, out KerberosKey asRepKey, out KrbEncryptionKey sessionKey) + { + var sourceRealmService = new FakeRealmService(sourceRealm); + + var sname = KrbPrincipalName.WellKnown.Krbtgt(destRealm); + var servicePrincipal = sourceRealmService.Principals.Find(sname, destRealm); + tgtKey = servicePrincipal.RetrieveLongTermCredential(); + + var clientPrincipal = sourceRealmService.Principals.Find(cname, sourceRealm); + asRepKey = clientPrincipal.RetrieveLongTermCredential(); + + sessionKey = KrbEncryptionKey.Generate(EncryptionType.AES128_CTS_HMAC_SHA256_128); + + DateTimeOffset now = DateTimeOffset.UtcNow; + + var encTicketPart = new KrbEncTicketPart() + { + CName = cname, + CRealm = sourceRealm, + Key = sessionKey, + AuthTime = now, + StartTime = now, + EndTime = now.AddHours(1), + RenewTill = now.AddDays(30), + Flags = TicketFlags.PreAuthenticated | TicketFlags.Initial | TicketFlags.Renewable | TicketFlags.Forwardable, + AuthorizationData = null, + CAddr = new KrbHostAddress[] { }, + Transited = new KrbTransitedEncoding() + }; + + KrbTicket ticket = new KrbTicket() + { + Realm = destRealm, + SName = sname, + EncryptedPart = KrbEncryptedData.Encrypt( + encTicketPart.EncodeApplication(), + tgtKey, + KeyUsage.Ticket + ) + }; + + KrbEncAsRepPart encAsRepPart = new KrbEncAsRepPart + { + AuthTime = encTicketPart.AuthTime, + StartTime = encTicketPart.AuthTime, + EndTime = encTicketPart.EndTime, + RenewTill = encTicketPart.RenewTill, + KeyExpiration = servicePrincipal.Expires, + Realm = destRealm, + SName = sname, + Flags = encTicketPart.Flags, + CAddr = encTicketPart.CAddr, + Key = sessionKey, + Nonce = 1234567890, + LastReq = new[] { new KrbLastReq { Type = 0, Value = now } }, + EncryptedPaData = new KrbMethodData + { + MethodData = new[] + { + new KrbPaData + { + Type = PaDataType.PA_SUPPORTED_ETYPES, + Value = servicePrincipal.SupportedEncryptionTypes.AsReadOnlyMemory(littleEndian: true) + } + } + } + }; + + KrbAsRep asRep = new KrbAsRep + { + CRealm = Realm2, + CName = new KrbPrincipalName + { + Type = PrincipalNameType.NT_PRINCIPAL, + Name = new[] { Upn2WithoutRealm } + }, + MessageType = MessageType.KRB_AS_REP, + Ticket = ticket, + EncPart = KrbEncryptedData.Encrypt( + encAsRepPart.EncodeApplication(), + asRepKey, + asRepKey.EncryptionType, + KeyUsage.EncAsRepPart + ) + }; + + return asRep; + } + private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey) { return RequestTgt(out sessionKey, out _); From a8a9ffa0918ae1a7b116aa3711e00f19e5e00341 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 12:46:40 -0700 Subject: [PATCH 03/11] Refactor to make TGT crealm, cname, srealm clear --- Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index 88be232b..badf061a 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -31,7 +31,7 @@ public class KdcHandlerTests : BaseTest [TestMethod] public void KdcAsReqHandler_Sync() { - KrbAsRep asRep = RequestTgt(out _, out KrbAsReq asReq); + KrbAsRep asRep = RequestTgt(cname: Upn, crealm: Realm, srealm: Realm, out _, out KrbAsReq asReq); Assert.IsNotNull(asRep); @@ -63,7 +63,7 @@ public void KdcAsReqHandler_Sync() [TestMethod] public void KdcTgsReqHandler_Sync() { - KrbAsRep asRep = RequestTgt(out KrbEncryptionKey tgtKey); + KrbAsRep asRep = RequestTgt(cname: Upn, crealm: Realm, srealm: Realm, out KrbEncryptionKey tgtKey); Assert.IsNotNull(asRep); @@ -291,14 +291,14 @@ private KrbAsRep CreateReferralTgt(string sourceRealm, string destRealm, KrbPrin return asRep; } - private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey) + private KrbAsRep RequestTgt(string cname, string crealm, string srealm, out KrbEncryptionKey sessionKey) { - return RequestTgt(out sessionKey, out _); + return RequestTgt(cname, crealm, srealm, out sessionKey, out _); } - private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey, out KrbAsReq asReq) + private KrbAsRep RequestTgt(string cname, string crealm, string srealm, out KrbEncryptionKey sessionKey, out KrbAsReq asReq) { - var cred = new KerberosPasswordCredential(Upn, "P@ssw0rd!") + var cred = new KerberosPasswordCredential(cname, "P@ssw0rd!", crealm) { // cheating by skipping the initial leg of requesting PA-type @@ -319,7 +319,7 @@ private KrbAsRep RequestTgt(out KrbEncryptionKey sessionKey, out KrbAsReq asReq) var handler = new KdcAsReqMessageHandler(asReq.EncodeApplication(), new KdcServerOptions { - DefaultRealm = Realm, + DefaultRealm = srealm, IsDebug = true, RealmLocator = realm => new FakeRealmService(realm) }); From 0b6cac07c1bffa5830bbf67b3bfab3b99232f002 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 12:50:17 -0700 Subject: [PATCH 04/11] Add more asserts to AS-REP checks --- Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index badf061a..8d3d94fe 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -33,17 +33,23 @@ public void KdcAsReqHandler_Sync() { KrbAsRep asRep = RequestTgt(cname: Upn, crealm: Realm, srealm: Realm, out _, out KrbAsReq asReq); + Assert.IsNotNull(asReq); Assert.IsNotNull(asRep); // RFC 4120 Section 3.1.5 Receipt of KRB_AS_REP Message // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields in // the cleartext portion of the reply match what it requested." + Assert.AreEqual(MessageType.KRB_AS_REP, asRep.MessageType); Assert.AreEqual(Realm, asReq.Body.Realm); Assert.AreEqual(Realm, asRep.CRealm); Assert.AreEqual(Upn, asReq.Body.CName.FullyQualifiedName); Assert.AreEqual(Upn, asRep.CName.FullyQualifiedName); + // Check that correct TGT was generated + Assert.AreEqual(Realm, asRep.Ticket.Realm); + Assert.AreEqual($"krbtgt/{Realm}", asRep.Ticket.SName.FullyQualifiedName); + // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside var realmService = new FakeRealmService(Realm); var tgtPrincipalName = KrbPrincipalName.WellKnown.Krbtgt(Realm); From af60d042a2449654f0681ea56cd5d693ce308ec4 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 12:57:40 -0700 Subject: [PATCH 05/11] Factor out and reuse AS-REP validations --- .../Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 86 +++++++++---------- 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index 8d3d94fe..35533101 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -33,37 +33,7 @@ public void KdcAsReqHandler_Sync() { KrbAsRep asRep = RequestTgt(cname: Upn, crealm: Realm, srealm: Realm, out _, out KrbAsReq asReq); - Assert.IsNotNull(asReq); - Assert.IsNotNull(asRep); - - // RFC 4120 Section 3.1.5 Receipt of KRB_AS_REP Message - // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields in - // the cleartext portion of the reply match what it requested." - Assert.AreEqual(MessageType.KRB_AS_REP, asRep.MessageType); - Assert.AreEqual(Realm, asReq.Body.Realm); - Assert.AreEqual(Realm, asRep.CRealm); - - Assert.AreEqual(Upn, asReq.Body.CName.FullyQualifiedName); - Assert.AreEqual(Upn, asRep.CName.FullyQualifiedName); - - // Check that correct TGT was generated - Assert.AreEqual(Realm, asRep.Ticket.Realm); - Assert.AreEqual($"krbtgt/{Realm}", asRep.Ticket.SName.FullyQualifiedName); - - // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside - var realmService = new FakeRealmService(Realm); - var tgtPrincipalName = KrbPrincipalName.WellKnown.Krbtgt(Realm); - var tgtEncPartKey = realmService.Principals.Find(tgtPrincipalName).RetrieveLongTermCredential(); - - var ticketEncPart = asRep.Ticket.EncryptedPart.Decrypt( - tgtEncPartKey, - KeyUsage.Ticket, - d => KrbEncTicketPart.DecodeApplication(d) - ); - - Assert.IsNotNull(ticketEncPart); - Assert.AreEqual(Realm, ticketEncPart.CRealm); - Assert.AreEqual(Upn, ticketEncPart.CName.FullyQualifiedName); + ValidateAsRep(asRep, expectedCName: Upn, expectedCRealm: Realm, expectedSRealm: Realm, asReq); } [TestMethod] @@ -138,21 +108,7 @@ public void KdcTgsReqHandler_Sync_ReferralTgt() KrbAsRep asRep = CreateReferralTgt(sourceRealm, destRealm, cname, out KerberosKey tgtKey, out KerberosKey asRepKey, out KrbEncryptionKey sessionKey); - // Check the TGT we just generated - Assert.IsNotNull(asRep); - Assert.AreEqual(sourceRealm, asRep.CRealm); - Assert.AreEqual(Upn2WithoutRealm, asRep.CName.FullyQualifiedName); - - // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside - var tgtEncPart = asRep.Ticket.EncryptedPart.Decrypt( - tgtKey, - KeyUsage.Ticket, - d => KrbEncTicketPart.DecodeApplication(d) - ); - - Assert.IsNotNull(tgtEncPart); - Assert.AreEqual(sourceRealm, tgtEncPart.CRealm); - Assert.AreEqual(Upn2WithoutRealm, tgtEncPart.CName.FullyQualifiedName); + ValidateAsRep(asRep, expectedCName: Upn2WithoutRealm, expectedCRealm: sourceRealm, expectedSRealm: destRealm); // Send a TGS-REQ to get a service ticket in the destination realm var spn = "host/foo." + Realm; @@ -208,6 +164,44 @@ out KrbEncryptionKey subSessionKey Assert.AreEqual(Upn2WithoutRealm, ticketEncPart.CName.FullyQualifiedName); } + private void ValidateAsRep(KrbAsRep asRep, string expectedCName, string expectedCRealm, string expectedSRealm, KrbAsReq asReq = null) + { + Assert.IsNotNull(asRep); + + // RFC 4120 Section 3.1.5 Receipt of KRB_AS_REP Message + // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields in + // the cleartext portion of the reply match what it requested." + Assert.AreEqual(MessageType.KRB_AS_REP, asRep.MessageType); + Assert.AreEqual(expectedCRealm, asRep.CRealm); + Assert.AreEqual(expectedCName, asRep.CName.FullyQualifiedName); + + // Optionally double check that the AS-REQ also matches + if (asReq != null) + { + Assert.AreEqual(Realm, asReq.Body.Realm); + Assert.AreEqual(Upn, asReq.Body.CName.FullyQualifiedName); + } + + // Check that correct TGT was generated + Assert.AreEqual(expectedSRealm, asRep.Ticket.Realm); + Assert.AreEqual($"krbtgt/{expectedSRealm}", asRep.Ticket.SName.FullyQualifiedName); + + // Clients can't decrypt TGTs usually, but for the sake of testing let's check what's inside + var realmService = new FakeRealmService(Realm); + var tgtPrincipalName = KrbPrincipalName.WellKnown.Krbtgt(Realm); + var tgtEncPartKey = realmService.Principals.Find(tgtPrincipalName).RetrieveLongTermCredential(); + + var ticketEncPart = asRep.Ticket.EncryptedPart.Decrypt( + tgtEncPartKey, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(ticketEncPart); + Assert.AreEqual(expectedCRealm, ticketEncPart.CRealm); + Assert.AreEqual(expectedCName, ticketEncPart.CName.FullyQualifiedName); + } + private KrbAsRep CreateReferralTgt(string sourceRealm, string destRealm, KrbPrincipalName cname, out KerberosKey tgtKey, out KerberosKey asRepKey, out KrbEncryptionKey sessionKey) { var sourceRealmService = new FakeRealmService(sourceRealm); From 7433d22486aaabf9390765f16be09c8058b3591a Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 14:10:19 -0700 Subject: [PATCH 06/11] Factor out and reuse TGS-REP validations --- .../Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs index 35533101..4783dc96 100644 --- a/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs +++ b/Tests/Tests.Kerberos.NET/Kdc/KdcHandlerTests.cs @@ -67,32 +67,17 @@ out KrbEncryptionKey sessionKey var tgsRep = KrbTgsRep.DecodeApplication(results); - Assert.IsNotNull(tgsRep); - - var encKdcRepPart = tgsRep.EncPart.Decrypt( - sessionKey.AsKey(), - KeyUsage.EncTgsRepPartSubSessionKey, - d => KrbEncTgsRepPart.DecodeApplication(d) - ); - - Assert.IsNotNull(encKdcRepPart); - - Assert.AreEqual(Realm, tgsRep.CRealm); - Assert.AreEqual(Upn, tgsRep.CName.FullyQualifiedName); - - // Clients can't decrypt service tickets usually, but for the sake of testing let's check what's inside var realmService = new FakeRealmService(Realm); - var ticketEncPart = realmService.Principals.Find(KrbPrincipalName.FromString(spn)).RetrieveLongTermCredential(); - - var serviceTicketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt( - ticketEncPart, - KeyUsage.Ticket, - d => KrbEncTicketPart.DecodeApplication(d) - ); - - Assert.IsNotNull(serviceTicketEncPart); - Assert.AreEqual(Realm, serviceTicketEncPart.CRealm); - Assert.AreEqual(Upn, serviceTicketEncPart.CName.FullyQualifiedName); + var ticketKey = realmService.Principals.Find(KrbPrincipalName.FromString(spn)).RetrieveLongTermCredential(); + + ValidateTgsRep( + tgsRep, + subSessionKey: sessionKey.AsKey(), + ticketKey: ticketKey, + expectedCName: Upn, + expectedCRealm: Realm, + expectedSName: spn, + expectedSRealm: Realm); } [TestMethod] @@ -135,33 +120,18 @@ out KrbEncryptionKey subSessionKey var tgsRep = KrbTgsRep.DecodeApplication(results); - Assert.IsNotNull(tgsRep); - - var encKdcRepPart = tgsRep.EncPart.Decrypt( - subSessionKey.AsKey(), - KeyUsage.EncTgsRepPartSubSessionKey, - d => KrbEncTgsRepPart.DecodeApplication(d) - ); - - Assert.IsNotNull(encKdcRepPart); - - Assert.AreEqual(sourceRealm, tgsRep.CRealm); - Assert.AreEqual(Upn2WithoutRealm, tgsRep.CName.FullyQualifiedName); - - // Clients can't decrypt service tickets usually, but for the sake of testing let's check what's inside var destRealmService = new FakeRealmService(destRealm); var servicePrincipal = destRealmService.Principals.Find(KrbPrincipalName.FromString(spn), destRealm); - var servicePrincipalKey = servicePrincipal.RetrieveLongTermCredential(); - - var ticketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt( - servicePrincipalKey, - KeyUsage.Ticket, - d => KrbEncTicketPart.DecodeApplication(d) - ); - - Assert.IsNotNull(ticketEncPart); - Assert.AreEqual(sourceRealm, ticketEncPart.CRealm); - Assert.AreEqual(Upn2WithoutRealm, ticketEncPart.CName.FullyQualifiedName); + var ticketKey = servicePrincipal.RetrieveLongTermCredential(); + + ValidateTgsRep( + tgsRep, + subSessionKey: subSessionKey.AsKey(), + ticketKey: ticketKey, + expectedCName: Upn2WithoutRealm, + expectedCRealm: sourceRealm, + expectedSName: spn, + expectedSRealm: destRealm); } private void ValidateAsRep(KrbAsRep asRep, string expectedCName, string expectedCRealm, string expectedSRealm, KrbAsReq asReq = null) @@ -202,6 +172,36 @@ private void ValidateAsRep(KrbAsRep asRep, string expectedCName, string expected Assert.AreEqual(expectedCName, ticketEncPart.CName.FullyQualifiedName); } + private void ValidateTgsRep(KrbTgsRep tgsRep, KerberosKey subSessionKey, KerberosKey ticketKey, string expectedCName, string expectedCRealm, string expectedSName, string expectedSRealm) + { + Assert.IsNotNull(tgsRep); + + var encKdcRepPart = tgsRep.EncPart.Decrypt( + subSessionKey, + KeyUsage.EncTgsRepPartSubSessionKey, + d => KrbEncTgsRepPart.DecodeApplication(d) + ); + + Assert.IsNotNull(encKdcRepPart); + Assert.AreEqual(expectedCRealm, tgsRep.CRealm); + Assert.AreEqual(expectedCName, tgsRep.CName.FullyQualifiedName); + + Assert.IsNotNull(tgsRep.Ticket); + Assert.AreEqual(expectedSRealm, tgsRep.Ticket.Realm); + Assert.AreEqual(expectedSName, tgsRep.Ticket.SName.FullyQualifiedName); + + // Clients can't decrypt service tickets usually, but for the sake of testing let's check what's inside + var ticketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt( + ticketKey, + KeyUsage.Ticket, + d => KrbEncTicketPart.DecodeApplication(d) + ); + + Assert.IsNotNull(ticketEncPart); + Assert.AreEqual(expectedCRealm, ticketEncPart.CRealm); + Assert.AreEqual(expectedCName, ticketEncPart.CName.FullyQualifiedName); + } + private KrbAsRep CreateReferralTgt(string sourceRealm, string destRealm, KrbPrincipalName cname, out KerberosKey tgtKey, out KerberosKey asRepKey, out KrbEncryptionKey sessionKey) { var sourceRealmService = new FakeRealmService(sourceRealm); From ea6ea747824e1d291019063d5b66413ac1f02479 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 14:16:03 -0700 Subject: [PATCH 07/11] Remove unnecessary ClientRealm field from context --- Kerberos.NET/Server/KdcTgsReqMessageHandler.cs | 2 +- Kerberos.NET/Server/PaDataTgsTicketHandler.cs | 1 - Kerberos.NET/Server/PreAuthenticationContext.cs | 5 ----- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs index 5c82ca0a..b5e7bc57 100644 --- a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs @@ -271,7 +271,7 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex EncryptedPartEType = context.EncryptedPartEType, ServicePrincipal = context.ServicePrincipal, ServicePrincipalKey = serviceKey, - ClientRealmName = context.ClientRealm, + ClientRealmName = context.Ticket.CRealm, RealmName = tgsReq.Body.Realm, Addresses = tgsReq.Body.Addresses, RenewTill = context.Ticket.RenewTill, diff --git a/Kerberos.NET/Server/PaDataTgsTicketHandler.cs b/Kerberos.NET/Server/PaDataTgsTicketHandler.cs index 3132ae44..1e1a41f8 100644 --- a/Kerberos.NET/Server/PaDataTgsTicketHandler.cs +++ b/Kerberos.NET/Server/PaDataTgsTicketHandler.cs @@ -105,7 +105,6 @@ public override KrbPaData Validate(KrbKdcReq asReq, PreAuthenticationContext con context.EncryptedPartKey = state.DecryptedApReq.SessionKey; context.Ticket = state.DecryptedApReq.Ticket; - context.ClientRealm = state.DecryptedApReq.Ticket.CRealm; return null; } diff --git a/Kerberos.NET/Server/PreAuthenticationContext.cs b/Kerberos.NET/Server/PreAuthenticationContext.cs index da6c3d4f..c729f03d 100644 --- a/Kerberos.NET/Server/PreAuthenticationContext.cs +++ b/Kerberos.NET/Server/PreAuthenticationContext.cs @@ -32,11 +32,6 @@ public class PreAuthenticationContext /// public KerberosKey EvidenceTicketKey { get; set; } - /// - /// The name of the realm that the client issued a TGT from. - /// - public string ClientRealm { get; set; } - /// /// The identity that will be the subject of the issued ticket. /// From ff029ad5c08735393d01ee4a61d149fe0aab39f4 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Wed, 8 Oct 2025 14:37:51 -0700 Subject: [PATCH 08/11] Copy cname from request in order to be spec compliant --- Kerberos.NET/Entities/Krb/KrbKdcRep.cs | 23 +++++++++++----- .../Entities/Krb/ServiceTicketRequest.cs | 26 ++++++++++++++++++- Kerberos.NET/Server/KdcAsReqMessageHandler.cs | 6 +++++ .../Server/KdcTgsReqMessageHandler.cs | 8 +++++- .../Client/HttpKerberosTransportTests.cs | 2 ++ .../Messages/KrbKdcRepTests.cs | 19 +++++++++----- 6 files changed, 69 insertions(+), 15 deletions(-) diff --git a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs index 7eafcfd8..5419183b 100644 --- a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs +++ b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs @@ -107,6 +107,19 @@ out MessageType messageType throw new InvalidOperationException("A service principal key must be provided"); } + if (request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently)) + { + if (request.ClientName == null) + { + throw new InvalidOperationException("Client name must be provided when IsolateRealmsConsistently is set"); + } + + if (request.ClientRealmName == null) + { + throw new InvalidOperationException("Client realm name must be provided when IsolateRealmsConsistently is set"); + } + } + if (request.Compatibility.HasFlag(KerberosCompatibilityFlags.NormalizeRealmsUppercase)) { request.RealmName = request.RealmName?.ToUpperInvariant(); @@ -194,8 +207,6 @@ private static KrbEncTicketPart CreateEncTicketPart( KrbEncryptionKey sessionKey ) { - var cname = CreateCNameForTicket(request); - var flags = request.Flags; if (request.PreAuthenticationData?.Any(r => r.Type == PaDataType.PA_REQ_ENC_PA_REP) ?? false) @@ -209,7 +220,7 @@ KrbEncryptionKey sessionKey var encTicketPart = new KrbEncTicketPart() { - CName = cname, + CName = request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? request.ClientName : CreateCNameForTicket(request), CRealm = request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? request.ClientRealmName : request.RealmName, Key = sessionKey, AuthTime = request.Now, @@ -235,11 +246,11 @@ private static KrbPrincipalName CreateCNameForTicket(ServiceTicketRequest reques { if (string.IsNullOrEmpty(request.SamAccountName)) { + // This is a bug, fixed under the IsolateRealmsConsistently flag. + // Client realm name is not necessarily the same as the (service) realm name return KrbPrincipalName.FromPrincipal( request.Principal, - realm: request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? - request.ClientRealmName : - request.RealmName + realm: request.RealmName ); } diff --git a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs index 611e9b52..b6b58ceb 100644 --- a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs +++ b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs @@ -28,7 +28,12 @@ public struct ServiceTicketRequest : IEquatable public KerberosKey KdcAuthorizationKey { get; set; } /// - /// The realm name for which the requested identity originated + /// The client name (cname) of the identity requesting the ticket + /// + public KrbPrincipalName ClientName { get; set; } + + /// + /// The client realm (crealm) name of the identity requesting the ticket /// public string ClientRealmName { get; set; } @@ -179,6 +184,21 @@ public bool Equals(ServiceTicketRequest other) return false; } + if (other.ClientName != this.ClientName) + { + return false; + } + + if (other.ClientRealmName != this.ClientRealmName) + { + return false; + } + + if (other.EncryptedPartEType != this.EncryptedPartEType) + { + return false; + } + if (other.EncryptedPartKey != this.EncryptedPartKey) { return false; @@ -281,6 +301,10 @@ public override int GetHashCode() { return EntityHashCode.GetHashCode( this.Addresses, + this.ClientName, + this.ClientRealmName, + this.Compatibility, + this.EncryptedPartEType, this.EncryptedPartKey, this.EndTime, this.Flags, diff --git a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs index f2449fe8..350ed8b6 100644 --- a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs @@ -167,6 +167,12 @@ private ReadOnlyMemory GenerateAsRep(KrbAsReq asReq, PreAuthenticationCont var rst = new ServiceTicketRequest { + // RFC 4120 section 3.1.5 Receipt of KRB_AS_REP Message + // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields + // in the cleartext portion of the reply match what it requested." + ClientName = asReq.Body.CName, + ClientRealmName = asReq.Body.Realm, + Principal = context.Principal, EncryptedPartKey = context.EncryptedPartKey, EncryptedPartEType = context.EncryptedPartEType, diff --git a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs index b5e7bc57..c0e756af 100644 --- a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs @@ -265,13 +265,19 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex var rst = new ServiceTicketRequest { + // RFC 4120, section 3.3.3 Generation of KRB_TGS_REP Message: + // "By default, the address field, the client's name and realm, the list of transited realms, the time + // of initial authentication, the expiration time, and the authorization data of the newly-issued + // ticket will be copied from the TGT or renewable ticket." + ClientName = context.Ticket.CName, + ClientRealmName = context.Ticket.CRealm, + KdcAuthorizationKey = context.EvidenceTicketKey, Principal = context.Principal, EncryptedPartKey = context.EncryptedPartKey, EncryptedPartEType = context.EncryptedPartEType, ServicePrincipal = context.ServicePrincipal, ServicePrincipalKey = serviceKey, - ClientRealmName = context.Ticket.CRealm, RealmName = tgsReq.Body.Realm, Addresses = tgsReq.Body.Addresses, RenewTill = context.Ticket.RenewTill, diff --git a/Tests/Tests.Kerberos.NET/Client/HttpKerberosTransportTests.cs b/Tests/Tests.Kerberos.NET/Client/HttpKerberosTransportTests.cs index b2fcb145..78e3ab56 100644 --- a/Tests/Tests.Kerberos.NET/Client/HttpKerberosTransportTests.cs +++ b/Tests/Tests.Kerberos.NET/Client/HttpKerberosTransportTests.cs @@ -92,6 +92,8 @@ protected override Task SendAsync(HttpRequestMessage reques var rst = new ServiceTicketRequest { + ClientName = KrbPrincipalName.FromPrincipal(principal), + ClientRealmName = Realm, Principal = principal, EncryptedPartKey = principalKey, ServicePrincipalKey = new KerberosKey(key: TgtKey, etype: EncryptionType.AES256_CTS_HMAC_SHA1_96) diff --git a/Tests/Tests.Kerberos.NET/Messages/KrbKdcRepTests.cs b/Tests/Tests.Kerberos.NET/Messages/KrbKdcRepTests.cs index 9432e648..12659968 100644 --- a/Tests/Tests.Kerberos.NET/Messages/KrbKdcRepTests.cs +++ b/Tests/Tests.Kerberos.NET/Messages/KrbKdcRepTests.cs @@ -112,12 +112,15 @@ public void CreateServiceTicket() var tgsRep = KrbKdcRep.GenerateServiceTicket(new ServiceTicketRequest { - EncryptedPartKey = key, + ClientName = KrbPrincipalName.FromString("blah@test.com"), + ClientRealmName = "test.com", + Principal = new FakeKerberosPrincipal("blah@test.com"), + ServicePrincipal = new FakeKerberosPrincipal("blah@blah.com"), ServicePrincipalKey = key, - Principal = new FakeKerberosPrincipal("blah@blah2.com"), RealmName = "blah.com", - ClientRealmName = "test.com", + + EncryptedPartKey = key, Compatibility = KerberosCompatibilityFlags.IsolateRealmsConsistently, }); @@ -125,11 +128,11 @@ public void CreateServiceTicket() Assert.AreEqual("blah.com", tgsRep.Ticket.Realm); Assert.AreEqual("blah@blah.com/blah.com", tgsRep.Ticket.SName.FullyQualifiedName); Assert.AreEqual("test.com", tgsRep.CRealm); - Assert.AreEqual("blah@blah2.com", tgsRep.CName.FullyQualifiedName); + Assert.AreEqual("blah@test.com", tgsRep.CName.FullyQualifiedName); var ticketEncPart = tgsRep.Ticket.EncryptedPart.Decrypt(key, KeyUsage.Ticket, KrbEncTicketPart.DecodeApplication); Assert.AreEqual("test.com", ticketEncPart.CRealm); - Assert.AreEqual("blah@blah2.com", ticketEncPart.CName.FullyQualifiedName); + Assert.AreEqual("blah@test.com", ticketEncPart.CName.FullyQualifiedName); } [TestMethod] @@ -184,12 +187,14 @@ string expectedCRealm var tgsRep = KrbKdcRep.GenerateServiceTicket(new ServiceTicketRequest { + Principal = new FakeKerberosPrincipal($"blah@{crealm}"), + ClientName = KrbPrincipalName.FromString($"blah@{crealm}"), + ClientRealmName = crealm, + EncryptedPartKey = key, ServicePrincipal = new FakeKerberosPrincipal("blah@blah.com"), ServicePrincipalKey = key, - Principal = new FakeKerberosPrincipal("blah@blah2.com"), RealmName = realm, - ClientRealmName = crealm, Compatibility = compatibilityFlags, }); From fad79cbbc9d5d569e6d6b8ce78079636b33b98ba Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Mon, 20 Oct 2025 17:46:33 -0700 Subject: [PATCH 09/11] Rework cname canonicalization --- Kerberos.NET/Entities/Krb/KrbKdcRep.cs | 57 +++++++++++-------- .../Entities/Krb/ServiceTicketRequest.cs | 7 ++- Kerberos.NET/Server/KdcAsReqMessageHandler.cs | 19 ++++--- .../Server/KdcTgsReqMessageHandler.cs | 19 ++++++- 4 files changed, 64 insertions(+), 38 deletions(-) diff --git a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs index 5419183b..36fb6230 100644 --- a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs +++ b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs @@ -107,19 +107,6 @@ out MessageType messageType throw new InvalidOperationException("A service principal key must be provided"); } - if (request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently)) - { - if (request.ClientName == null) - { - throw new InvalidOperationException("Client name must be provided when IsolateRealmsConsistently is set"); - } - - if (request.ClientRealmName == null) - { - throw new InvalidOperationException("Client realm name must be provided when IsolateRealmsConsistently is set"); - } - } - if (request.Compatibility.HasFlag(KerberosCompatibilityFlags.NormalizeRealmsUppercase)) { request.RealmName = request.RealmName?.ToUpperInvariant(); @@ -220,7 +207,7 @@ KrbEncryptionKey sessionKey var encTicketPart = new KrbEncTicketPart() { - CName = request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? request.ClientName : CreateCNameForTicket(request), + CName = CreateCNameForTicket(request), CRealm = request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? request.ClientRealmName : request.RealmName, Key = sessionKey, AuthTime = request.Now, @@ -244,21 +231,41 @@ KrbEncryptionKey sessionKey private static KrbPrincipalName CreateCNameForTicket(ServiceTicketRequest request) { - if (string.IsNullOrEmpty(request.SamAccountName)) + // If ClientName is explicitly set, use that. This is the preferred method for the caller to indicate what + // cname should be used. + if (request.ClientName != null) { - // This is a bug, fixed under the IsolateRealmsConsistently flag. - // Client realm name is not necessarily the same as the (service) realm name - return KrbPrincipalName.FromPrincipal( - request.Principal, - realm: request.RealmName - ); + return request.ClientName; } - return new KrbPrincipalName + // Otherwise, if SamAccountName is set, use that as the principal name. + // This is not the recommended method, but is supported for backwards compatibility. +#pragma warning disable CS0618 // Type or member is obsolete + if (!string.IsNullOrEmpty(request.SamAccountName)) { - Type = PrincipalNameType.NT_PRINCIPAL, - Name = new[] { request.SamAccountName } - }; + return new KrbPrincipalName + { + Type = PrincipalNameType.NT_PRINCIPAL, + Name = new[] { request.SamAccountName } + }; + } +#pragma warning restore CS0618 // Type or member is obsolete + + // Lastly, if neither are set, derive from the principal. + // + // Note: this might not be correct in all scenarios. For instance, if the client does not accept + // name canonicalization (i.e., the Canonicalize flag is not set), then it's not spec-compliant to deviate + // from the requested cname. Also, in TGS-REP, the cname should match what's in the TGT, and should not be + // derived from the found principal. + // + // Note: historically Kerberos.NET had a bug where the service realm was used to derive cname from the principal. + // However, it should be the client realm. This has been corrected under the IsolateRealmsConsistently flag. + return KrbPrincipalName.FromPrincipal( + request.Principal, + realm: request.Compatibility.HasFlag(KerberosCompatibilityFlags.IsolateRealmsConsistently) ? + request.ClientRealmName : + request.RealmName + ); } private static IEnumerable GenerateAuthorizationData(ServiceTicketRequest request) diff --git a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs index b6b58ceb..72e52bd3 100644 --- a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs +++ b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs @@ -28,7 +28,8 @@ public struct ServiceTicketRequest : IEquatable public KerberosKey KdcAuthorizationKey { get; set; } /// - /// The client name (cname) of the identity requesting the ticket + /// The client name (cname) of the identity requesting the ticket. + /// If this is not set, or will be used. /// public KrbPrincipalName ClientName { get; set; } @@ -115,8 +116,8 @@ public struct ServiceTicketRequest : IEquatable /// /// SAM account name to be used to generate TGT for Windows specific user principal. - /// If this parameter contains valid string (not empty), CName of encrypted part of ticket - /// will be created based on provided SamAccountName. + /// This is only used if (1) is not set, and (2) it is a valid (not empty) string. + /// Used to compute the cname of a KDC-REP. /// public string SamAccountName { get; set; } diff --git a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs index 350ed8b6..1a498ea6 100644 --- a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs @@ -167,12 +167,7 @@ private ReadOnlyMemory GenerateAsRep(KrbAsReq asReq, PreAuthenticationCont var rst = new ServiceTicketRequest { - // RFC 4120 section 3.1.5 Receipt of KRB_AS_REP Message - // "If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields - // in the cleartext portion of the reply match what it requested." - ClientName = asReq.Body.CName, ClientRealmName = asReq.Body.Realm, - Principal = context.Principal, EncryptedPartKey = context.EncryptedPartKey, EncryptedPartEType = context.EncryptedPartEType, @@ -199,10 +194,20 @@ private ReadOnlyMemory GenerateAsRep(KrbAsReq asReq, PreAuthenticationCont // Canonicalize means the CName in the reply is allowed to be different from the CName in the request. // If this is not allowed, then we must use the CName from the request. Otherwise, we will set the CName - // to what we have in our realm, i.e. user@realm. + // to what we have in our realm, i.e. user@realm (which will be inferred from the Principal set above). + // + // RFC 4120 section 3.1.5. Receipt of KRB_AS_REP Message + // ----------------------------------------------------- + // If the reply message type is KRB_AS_REP, then the client verifies that the cname and crealm fields in + // the cleartext portion of the reply match what it requested. + // + // RFC 6806 section 6. Name Canonicalization + // ----------------------------------------- + // If the "canonicalize" KDC option is set, then the KDC MAY change the client and server principal names + // and types in the AS response and ticket returned from those in the request. if (!asReq.Body.KdcOptions.HasFlag(KdcOptions.Canonicalize)) { - rst.SamAccountName = asReq.Body.CName.FullyQualifiedName; + rst.ClientName = asReq.Body.CName; } if (rst.EncryptedPartKey == null) diff --git a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs index c0e756af..32916413 100644 --- a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs @@ -265,10 +265,21 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex var rst = new ServiceTicketRequest { - // RFC 4120, section 3.3.3 Generation of KRB_TGS_REP Message: + // In TGS-REP, we should always reply with cname/crealm copied from the TGT, even when the Canonicalize + // flag is set in TGS-REQ. + // + // RFC 4120 § 3.3.3 Generation of KRB_TGS_REP Message + // -------------------------------------------------- // "By default, the address field, the client's name and realm, the list of transited realms, the time // of initial authentication, the expiration time, and the authorization data of the newly-issued // ticket will be copied from the TGT or renewable ticket." + // + // RFC 6806 § 6. Name Canonicalization + // ----------------------------------- + // "If the "canonicalize" KDC option is set, then the KDC MAY change the client and server principal + // names and types in the AS response and ticket returned from those in the request. Names MUST NOT be + // changed in the response to a TGS request, although it is common for KDCs to maintain a set of + // aliases for service principals." ClientName = context.Ticket.CName, ClientRealmName = context.Ticket.CRealm, @@ -296,12 +307,14 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex Compatibility = this.RealmService.Settings.Compatibility, }; - // this introduced an annoying regression in a separate party and this is a workaround to make sure it - // uses the original behavior in cases where that's expected + // The code below introduced an annoying regression in a separate party. + // The compatibility flag is a workaround to make sure it can use the original behavior in cases where + // that's expected. if (!this.RealmService.Settings.Compatibility.HasFlag(KerberosCompatibilityFlags.DoNotCanonicalizeTgsReqFromTgt) && tgsReq.Body.KdcOptions.HasFlag(KdcOptions.Canonicalize)) { + rst.ClientName = null; rst.SamAccountName = context.GetState(PaDataType.PA_TGS_REQ).DecryptedApReq.Ticket.CName.FullyQualifiedName; } From 73db490f66df5942f469fb4d368997cc5af1f8bf Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Mon, 20 Oct 2025 17:46:47 -0700 Subject: [PATCH 10/11] Make SamAccountName obsolete --- Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs | 7 +++++++ Kerberos.NET/Server/KdcTgsReqMessageHandler.cs | 2 ++ Tests/Tests.Kerberos.NET/Messages/KrbtgtTests.cs | 2 ++ 3 files changed, 11 insertions(+) diff --git a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs index 72e52bd3..0f7a8cf5 100644 --- a/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs +++ b/Kerberos.NET/Entities/Krb/ServiceTicketRequest.cs @@ -119,6 +119,9 @@ public struct ServiceTicketRequest : IEquatable /// This is only used if (1) is not set, and (2) it is a valid (not empty) string. /// Used to compute the cname of a KDC-REP. /// + [Obsolete( + "Using SamAccountName may cause non spec-compliant behavior. Use ClientName instead to set the client " + + "principal name that should be used in Kerberos responses in a spec-compliant manner.")] public string SamAccountName { get; set; } /// @@ -270,10 +273,12 @@ public bool Equals(ServiceTicketRequest other) return false; } +#pragma warning disable CS0618 // Type or member is obsolete if (other.SamAccountName != this.SamAccountName) { return false; } +#pragma warning restore CS0618 // Type or member is obsolete if (other.ServicePrincipal != this.ServicePrincipal) { @@ -300,6 +305,7 @@ public bool Equals(ServiceTicketRequest other) public override int GetHashCode() { +#pragma warning disable CS0618 // Type or member is obsolete return EntityHashCode.GetHashCode( this.Addresses, this.ClientName, @@ -326,6 +332,7 @@ public override int GetHashCode() this.StartTime, this.Compatibility ); +#pragma warning restore CS0618 // Type or member is obsolete } public static bool operator ==(ServiceTicketRequest left, ServiceTicketRequest right) diff --git a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs index 32916413..75b98d90 100644 --- a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs @@ -315,7 +315,9 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex tgsReq.Body.KdcOptions.HasFlag(KdcOptions.Canonicalize)) { rst.ClientName = null; +#pragma warning disable CS0612 // Type or member is obsolete rst.SamAccountName = context.GetState(PaDataType.PA_TGS_REQ).DecryptedApReq.Ticket.CName.FullyQualifiedName; +#pragma warning restore CS0612 // Type or member is obsolete } // this is set here instead of in GenerateServiceTicket because GST is used by unit tests to diff --git a/Tests/Tests.Kerberos.NET/Messages/KrbtgtTests.cs b/Tests/Tests.Kerberos.NET/Messages/KrbtgtTests.cs index fffa9305..4a19451e 100644 --- a/Tests/Tests.Kerberos.NET/Messages/KrbtgtTests.cs +++ b/Tests/Tests.Kerberos.NET/Messages/KrbtgtTests.cs @@ -141,6 +141,7 @@ string expectedRealm var principalKey = principal.RetrieveLongTermCredential(); +#pragma warning disable CS0618 // Type or member is obsolete var rst = new ServiceTicketRequest { SamAccountName = TestSamAccountName, @@ -149,6 +150,7 @@ string expectedRealm EncryptedPartKey = principalKey, ServicePrincipalKey = new KerberosKey(key: TgtKey, etype: EncryptionType.AES256_CTS_HMAC_SHA1_96) }; +#pragma warning restore CS0618 // Type or member is obsolete var tgt = KrbAsRep.GenerateTgt(rst, realmService); From cda9b2885deb32784f02248bfd6518f1c143bc16 Mon Sep 17 00:00:00 2001 From: Maxime Kjaer Date: Mon, 20 Oct 2025 19:01:03 -0700 Subject: [PATCH 11/11] Add old behavior under feature flag --- Kerberos.NET/Entities/Krb/KrbKdcRep.cs | 5 +- Kerberos.NET/Server/KdcAsReqMessageHandler.cs | 12 ++++- .../Server/KdcTgsReqMessageHandler.cs | 51 ++++++++++--------- .../Server/KerberosCompatibilityFlags.cs | 7 +++ Tests/Tests.Kerberos.NET/FakeRealmService.cs | 2 +- 5 files changed, 49 insertions(+), 28 deletions(-) diff --git a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs index 36fb6230..d06c4c71 100644 --- a/Kerberos.NET/Entities/Krb/KrbKdcRep.cs +++ b/Kerberos.NET/Entities/Krb/KrbKdcRep.cs @@ -243,6 +243,8 @@ private static KrbPrincipalName CreateCNameForTicket(ServiceTicketRequest reques #pragma warning disable CS0618 // Type or member is obsolete if (!string.IsNullOrEmpty(request.SamAccountName)) { + // Note that the name is returned in a single part here, even if the request may have had multiple parts. + // This may be okay for AS-REQs with Canonicalize set, but it is not spec-compliant for other scenarios. return new KrbPrincipalName { Type = PrincipalNameType.NT_PRINCIPAL, @@ -256,7 +258,8 @@ private static KrbPrincipalName CreateCNameForTicket(ServiceTicketRequest reques // Note: this might not be correct in all scenarios. For instance, if the client does not accept // name canonicalization (i.e., the Canonicalize flag is not set), then it's not spec-compliant to deviate // from the requested cname. Also, in TGS-REP, the cname should match what's in the TGT, and should not be - // derived from the found principal. + // derived from the found principal. It is the responsibility of the caller to decide whether the request + // warrants passing Principal only, or forcing a specific cname via ClientName. // // Note: historically Kerberos.NET had a bug where the service realm was used to derive cname from the principal. // However, it should be the client realm. This has been corrected under the IsolateRealmsConsistently flag. diff --git a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs index 1a498ea6..e2e57ea5 100644 --- a/Kerberos.NET/Server/KdcAsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcAsReqMessageHandler.cs @@ -207,7 +207,17 @@ private ReadOnlyMemory GenerateAsRep(KrbAsReq asReq, PreAuthenticationCont // and types in the AS response and ticket returned from those in the request. if (!asReq.Body.KdcOptions.HasFlag(KdcOptions.Canonicalize)) { - rst.ClientName = asReq.Body.CName; + if (this.RealmService.Settings.Compatibility.HasFlag(KerberosCompatibilityFlags.EnableSpecCompliantCNameHandling)) + { + rst.ClientName = asReq.Body.CName; + } + else + { + #pragma warning disable CS0618 // Type or member is obsolete + rst.SamAccountName = asReq.Body.CName.FullyQualifiedName; + #pragma warning restore CS0618 // Type or member is obsolete + } + } if (rst.EncryptedPartKey == null) diff --git a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs index 75b98d90..ded21031 100644 --- a/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs +++ b/Kerberos.NET/Server/KdcTgsReqMessageHandler.cs @@ -265,24 +265,7 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex var rst = new ServiceTicketRequest { - // In TGS-REP, we should always reply with cname/crealm copied from the TGT, even when the Canonicalize - // flag is set in TGS-REQ. - // - // RFC 4120 § 3.3.3 Generation of KRB_TGS_REP Message - // -------------------------------------------------- - // "By default, the address field, the client's name and realm, the list of transited realms, the time - // of initial authentication, the expiration time, and the authorization data of the newly-issued - // ticket will be copied from the TGT or renewable ticket." - // - // RFC 6806 § 6. Name Canonicalization - // ----------------------------------- - // "If the "canonicalize" KDC option is set, then the KDC MAY change the client and server principal - // names and types in the AS response and ticket returned from those in the request. Names MUST NOT be - // changed in the response to a TGS request, although it is common for KDCs to maintain a set of - // aliases for service principals." - ClientName = context.Ticket.CName, ClientRealmName = context.Ticket.CRealm, - KdcAuthorizationKey = context.EvidenceTicketKey, Principal = context.Principal, EncryptedPartKey = context.EncryptedPartKey, @@ -307,17 +290,35 @@ public override ReadOnlyMemory ExecuteCore(PreAuthenticationContext contex Compatibility = this.RealmService.Settings.Compatibility, }; - // The code below introduced an annoying regression in a separate party. - // The compatibility flag is a workaround to make sure it can use the original behavior in cases where - // that's expected. - - if (!this.RealmService.Settings.Compatibility.HasFlag(KerberosCompatibilityFlags.DoNotCanonicalizeTgsReqFromTgt) && + if (this.RealmService.Settings.Compatibility.HasFlag(KerberosCompatibilityFlags.EnableSpecCompliantCNameHandling)) + { + // In TGS-REP, we should always reply with cname/crealm copied from the TGT, even when the Canonicalize + // flag is set in TGS-REQ. + // + // RFC 4120 § 3.3.3 Generation of KRB_TGS_REP Message + // -------------------------------------------------- + // "By default, the address field, the client's name and realm, the list of transited realms, the time + // of initial authentication, the expiration time, and the authorization data of the newly-issued + // ticket will be copied from the TGT or renewable ticket." + // + // RFC 6806 § 6. Name Canonicalization + // ----------------------------------- + // "If the "canonicalize" KDC option is set, then the KDC MAY change the client and server principal + // names and types in the AS response and ticket returned from those in the request. Names MUST NOT be + // changed in the response to a TGS request, although it is common for KDCs to maintain a set of + // aliases for service principals." + rst.ClientName = context.Ticket.CName; + } + else if (!this.RealmService.Settings.Compatibility.HasFlag(KerberosCompatibilityFlags.DoNotCanonicalizeTgsReqFromTgt) && tgsReq.Body.KdcOptions.HasFlag(KdcOptions.Canonicalize)) { - rst.ClientName = null; -#pragma warning disable CS0612 // Type or member is obsolete + // The code below introduced an annoying regression in a separate party. + // The compatibility flag is a workaround to make sure it can use the original behavior in cases where + // that's expected. + + #pragma warning disable CS0612 // Type or member is obsolete rst.SamAccountName = context.GetState(PaDataType.PA_TGS_REQ).DecryptedApReq.Ticket.CName.FullyQualifiedName; -#pragma warning restore CS0612 // Type or member is obsolete + #pragma warning restore CS0612 // Type or member is obsolete } // this is set here instead of in GenerateServiceTicket because GST is used by unit tests to diff --git a/Kerberos.NET/Server/KerberosCompatibilityFlags.cs b/Kerberos.NET/Server/KerberosCompatibilityFlags.cs index 7323aaeb..1ab58f62 100644 --- a/Kerberos.NET/Server/KerberosCompatibilityFlags.cs +++ b/Kerberos.NET/Server/KerberosCompatibilityFlags.cs @@ -33,5 +33,12 @@ public enum KerberosCompatibilityFlags /// fields or properties. This separates the names into two. /// IsolateRealmsConsistently = 1 << 2, + + /// + /// CName handling was historically non-spec compliant in some cases. + /// This flag enables handling that more strictly adheres to the spec, for better compliance + /// with other implementations. + /// + EnableSpecCompliantCNameHandling = 1 << 3, } } diff --git a/Tests/Tests.Kerberos.NET/FakeRealmService.cs b/Tests/Tests.Kerberos.NET/FakeRealmService.cs index 9ae0e7e3..77a95d75 100644 --- a/Tests/Tests.Kerberos.NET/FakeRealmService.cs +++ b/Tests/Tests.Kerberos.NET/FakeRealmService.cs @@ -13,7 +13,7 @@ public class FakeRealmService : IRealmService { private readonly KerberosCompatibilityFlags compatibilityFlags; - public FakeRealmService(string realm, Krb5Config config = null, KerberosCompatibilityFlags compatibilityFlags = KerberosCompatibilityFlags.IsolateRealmsConsistently) + public FakeRealmService(string realm, Krb5Config config = null, KerberosCompatibilityFlags compatibilityFlags = KerberosCompatibilityFlags.IsolateRealmsConsistently | KerberosCompatibilityFlags.EnableSpecCompliantCNameHandling) { this.Name = realm; this.Configuration = config ?? Krb5Config.Kdc();