]> git.ipfire.org Git - thirdparty/samba.git/commitdiff
tests:krb5 Add key trust tests
authorGary Lockyer <gary@catalyst.net.nz>
Sun, 10 Aug 2025 23:44:14 +0000 (11:44 +1200)
committerDouglas Bagnall <dbagnall@samba.org>
Tue, 16 Sep 2025 22:22:34 +0000 (22:22 +0000)
Add tests to verify Key Trust authentication

Signed-off-by: Gary Lockyer <gary@catalyst.net.nz>
Reviewed-by: Jennifer Sutton <jennifersutton@catalyst.net.nz>
python/samba/tests/krb5/kdc_base_test.py
python/samba/tests/krb5/key_trust_tests.py [new file with mode: 0755]
selftest/knownfail
selftest/knownfail_mit_kdc
source4/selftest/tests.py

index a233d37506b0acfd9c7748a7f26b7b511050fe24..4cf87fa0ba55de8f864b221e897e1cc232f864d2 100644 (file)
@@ -2536,6 +2536,7 @@ class KDCBaseTest(TestCaseInTempDir, RawKerberosTest):
             'logon_hours': None,
             'smartcard_required': False,
             'enabled': True,
+            'keycredlink' : None,
         }
 
         account_opts = {
@@ -2595,7 +2596,8 @@ class KDCBaseTest(TestCaseInTempDir, RawKerberosTest):
                             assigned_silo,
                             logon_hours,
                             smartcard_required,
-                            enabled):
+                            enabled,
+                            keycredlink):
         self.assertNotEqual(account_type, self.AccountType.TRUST)
         if account_type is self.AccountType.USER:
             self.assertIsNone(delegation_to_spn)
@@ -2667,6 +2669,9 @@ class KDCBaseTest(TestCaseInTempDir, RawKerberosTest):
         if assigned_silo is not None:
             details['msDS-AssignedAuthNPolicySilo'] = assigned_silo
 
+        if keycredlink is not None:
+            details['msDS-KeyCredentialLink'] = keycredlink
+
         if selective_auth_allowed_sid is not None:
             if account_type is self.AccountType.GROUP_MANAGED_SERVICE:
                 oclass = "msDS-GroupManagedServiceAccount"
diff --git a/python/samba/tests/krb5/key_trust_tests.py b/python/samba/tests/krb5/key_trust_tests.py
new file mode 100755 (executable)
index 0000000..78410b8
--- /dev/null
@@ -0,0 +1,497 @@
+#!/usr/bin/env python3
+# Unix SMB/CIFS implementation.
+#
+# Tests for Key Trust authentication
+#
+# Based on pkinit_tests.py
+#
+# Copyright (C) Gary Lockyer <gary@catalyst.net.nz> 2025
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import sys
+import os
+
+sys.path.insert(0, "bin/python")
+os.environ["PYTHONUNBUFFERED"] = "1"
+
+from datetime import datetime, timedelta
+
+from cryptography import x509
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.x509.oid import NameOID
+
+from samba.dcerpc import security
+from samba.domain.models import User
+from samba.key_credential_link import create_key_credential_link
+from samba.tests.krb5 import kcrypto
+from samba.tests.krb5.kdc_base_test import KDCBaseTest
+from samba.tests.krb5.raw_testcase import PkInit, RawKerberosTest
+from samba.tests.krb5.rfc4120_constants import (
+    DES_EDE3_CBC,
+    KDC_ERR_CLIENT_NOT_TRUSTED,
+    NT_PRINCIPAL,
+    PADATA_PK_AS_REQ,
+)
+import samba.tests.krb5.rfc4120_pyasn1 as krb5_asn1
+
+SidType = RawKerberosTest.SidType
+
+global_asn1_print = False
+global_hexdump = False
+
+
+class KeyTrustTests(KDCBaseTest):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        self.do_asn1_print = global_asn1_print
+        self.do_hexdump = global_hexdump
+
+    def test_key_trust_user(self):
+        """
+        Test key trust logon for a normal account
+        """
+        client_creds = self._get_creds()
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+        public_key = certificate.public_bytes(Encoding.DER)
+        self._add_key_cred_link(client_creds, public_key)
+
+        self._pkinit_req(client_creds, target_creds, certificate)
+
+    def test_key_trust_user_mismatched_keys(self):
+        """
+        Test key trust logon for a normal account, where the certificate
+        public key does not match the msDS-KeyCredentialLink value
+        """
+        client_creds = self._get_creds()
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+
+        private_key = rsa.generate_private_key(
+            public_exponent=65537, key_size=2048, backend=default_backend()
+        )
+        public_key = private_key.public_key().public_bytes(
+            encoding=Encoding.DER, format=PublicFormat.SubjectPublicKeyInfo
+        )
+        self._add_key_cred_link(client_creds, public_key)
+
+        self._pkinit_req(
+            client_creds,
+            target_creds,
+            certificate,
+            expect_error=KDC_ERR_CLIENT_NOT_TRUSTED,
+        )
+
+    def test_key_trust_user_no_keys(self):
+        """
+        Test key trust logon for a normal account,
+        with no msDS-KeyCredentialLink
+        """
+        client_creds = self._get_creds()
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+
+        self._pkinit_req(
+            client_creds,
+            target_creds,
+            certificate,
+            expect_error=KDC_ERR_CLIENT_NOT_TRUSTED,
+        )
+
+    def test_key_trust_computer(self):
+        """
+        Test key trust logon with a computer account.
+        """
+        client_creds = self._get_creds(self.AccountType.COMPUTER)
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+        public_key = certificate.public_bytes(Encoding.DER)
+        self._add_key_cred_link(client_creds, public_key)
+
+        self._pkinit_req(client_creds, target_creds, certificate)
+
+    def test_key_trust_computer_mismatched_keys(self):
+        """
+        Test key trust logon for a computer account, where the certificate
+        public key does not match the msDS-KeyCredentialLink value
+        """
+        client_creds = self._get_creds(self.AccountType.COMPUTER)
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+
+        private_key = rsa.generate_private_key(
+            public_exponent=65537, key_size=2048, backend=default_backend()
+        )
+        public_key = private_key.public_key().public_bytes(
+            encoding=Encoding.DER, format=PublicFormat.SubjectPublicKeyInfo
+        )
+        self._add_key_cred_link(client_creds, public_key)
+
+        self._pkinit_req(
+            client_creds,
+            target_creds,
+            certificate,
+            expect_error=KDC_ERR_CLIENT_NOT_TRUSTED,
+        )
+
+    def test_key_trust_computer_no_keys(self):
+        """
+        Test key trust logon with a computer account,
+        with no msDS-KeyCredentialLink
+        """
+        client_creds = self._get_creds(self.AccountType.COMPUTER)
+        target_creds = self.get_service_creds()
+        certificate = self._create_certificate(client_creds)
+
+        self._pkinit_req(
+            client_creds,
+            target_creds,
+            certificate,
+            expect_error=KDC_ERR_CLIENT_NOT_TRUSTED,
+        )
+
+    def _get_creds(self, account_type=KDCBaseTest.AccountType.USER, use_cache=False):
+        """
+        Return credentials with an account having a UPN for performing
+        PK-INIT.
+
+        Modified from the version in python/samba/tests/krb5/pkinit_tests.py
+        """
+        samdb = self.get_samdb()
+        realm = samdb.domain_dns_name().upper()
+
+        opts = {"upn": f"{{account}}.{realm}@{realm}"}
+
+        return self.get_cached_creds(
+            account_type=account_type, opts=opts, use_cache=use_cache
+        )
+
+    def _add_key_cred_link(self, creds, public_key):
+        """
+        Update the msDS-KeyCredentialLink for the user specified in creds with
+        the supplied public key
+        """
+        link = create_key_credential_link(self.get_samdb(), creds.get_dn(), public_key)
+
+        user = User.find(self.get_samdb(), creds.get_username())
+        self.assertIsNotNone(user)
+        if user is not None:
+            user.key_credential_link = link
+            user.save(self.get_samdb())
+
+    def _create_certificate(self, creds):
+        """
+        Create a new self signed certificate
+
+        Modified from the version in python/samba/tests/krb5/pkinit_tests.py
+        """
+
+        certificate_signature = hashes.SHA256
+
+        user_name = creds.get_username()
+
+        builder = x509.CertificateBuilder()
+
+        # Add the subject name.
+        cert_name = f"{user_name}@{creds.get_realm().lower()}"
+        subject_name = x509.Name(
+            [
+                # This name can be anything; it isn’t needed to authorize the
+                # user. The SubjectAlternativeName is used for that instead.
+                x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
+                x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "SambaState"),
+                x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SambaSelfTesting"),
+                x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Users"),
+                x509.NameAttribute(NameOID.COMMON_NAME, f"{cert_name}"),
+            ]
+        )
+        builder = builder.subject_name(subject_name)
+
+        # The new certificate is self signed
+        builder = builder.issuer_name(subject_name)
+
+        one_day = timedelta(1, 0, 0)
+
+        # Put the certificate start time in the past to avoid issues where the
+        # KDC considers the certificate to be invalid due to clock skew. Note
+        # that if the certificate predates the existence of the account in AD,
+        # Windows will refuse authentication unless a strong mapping is
+        # present (in the certificate, or in AD).
+        # See https://support.microsoft.com/en-us/topic/kb5014754-certificate-based-authentication-changes-on-windows-domain-controllers-ad2c23b0-15d8-4340-a468-4d4f3b188f16#ID0EFR
+        builder = builder.not_valid_before(datetime.today() - one_day)
+        builder = builder.not_valid_after(datetime.today() + (one_day * 30))
+
+        builder = builder.serial_number(x509.random_serial_number())
+
+        public_key = creds.get_public_key()
+        builder = builder.public_key(public_key)
+
+        # Add the SubjectAlternativeName. Windows uses this to map the account
+        # to the certificate.
+        id_pkinit_ms_san = x509.ObjectIdentifier(str(krb5_asn1.id_pkinit_ms_san))
+        encoded_upn = self.der_encode(creds.get_upn(), asn1Spec=krb5_asn1.MS_UPN_SAN())
+        ms_upn_san = x509.OtherName(id_pkinit_ms_san, encoded_upn)
+        builder = builder.add_extension(
+            x509.SubjectAlternativeName([ms_upn_san]),
+            critical=False,
+        )
+
+        # The key identifier is used to identify the certificate.
+        subject_key_id = x509.SubjectKeyIdentifier.from_public_key(public_key)
+        builder = builder.add_extension(
+            subject_key_id,
+            critical=True,
+        )
+
+        # Add the key usages for which this certificate is valid.
+        builder = builder.add_extension(
+            # Heimdal requires that the certificate be valid for digital
+            # signatures.
+            x509.KeyUsage(digital_signature=True,
+                          content_commitment=False,
+                          key_encipherment=False,
+                          data_encipherment=False,
+                          key_agreement=False,
+                          key_cert_sign=False,
+                          crl_sign=False,
+                          encipher_only=False,
+                          decipher_only=False),
+                          critical=True,
+        )
+
+        certificate = builder.sign(
+            private_key=creds.get_private_key(),
+            algorithm=certificate_signature(),
+            backend=default_backend(),
+        )
+
+        return certificate
+
+    def _pkinit_req(
+        self,
+        creds,
+        target_creds,
+        certificate,
+        expect_error=0,
+    ):
+        """
+        Modified from the version in python/samba/tests/krb5/pkinit_tests.py
+        """
+        signature_algorithm = krb5_asn1.sha1WithRSAEncryption
+        signature_algorithm_id = self.AlgorithmIdentifier_create(signature_algorithm)
+
+        private_key = creds.get_private_key()
+
+        preauth_key = private_key
+
+        pk_nonce = self.get_Nonce()
+
+        def generate_pk_padata(_kdc_exchange_dict, _callback_dict, req_body):
+            # Suppress unused parameter warnings
+            _ = (_kdc_exchange_dict, _callback_dict)
+
+            checksum_blob = self.der_encode(req_body, asn1Spec=krb5_asn1.KDC_REQ_BODY())
+
+            # Calculate the SHA1 checksum over the KDC-REQ-BODY. This checksum
+            # is required to be present in the authenticator, and must be SHA1.
+            digest = hashes.Hash(hashes.SHA1(), default_backend())
+            digest.update(checksum_blob)
+            digest = digest.finalize()
+
+            ctime, cusec = self.get_KerberosTimeWithUsec()
+
+            # Create the authenticator, which shows that we had possession of
+            # the private key at some point.
+            authenticator_obj = self.PKAuthenticator_create(
+                cusec, ctime, pk_nonce, pa_checksum=digest
+            )
+
+            client_public_value = None
+
+            # An optional set of algorithms supported by the client in
+            # decreasing order of preference. For whatever reason, if this
+            # field is missing or empty, Windows will respond with a slightly
+            # differently encoded ReplyKeyPack, wrapping it first in a
+            # ContentInfo structure.
+            supported_cms_types = [
+                self.AlgorithmIdentifier_create(
+                    krb5_asn1.id_pkcs1_sha256WithRSAEncryption
+                ),
+            ]
+            auth_pack_obj = self.AuthPack_create(
+                authenticator_obj,
+                client_public_value=client_public_value,
+                supported_cms_types=supported_cms_types,
+            )
+
+            asn1_spec = krb5_asn1.AuthPack
+            auth_pack = self.der_encode(auth_pack_obj, asn1Spec=asn1_spec())
+
+            signature_hash = self.hash_from_algorithm(signature_algorithm)
+
+            pad = padding.PKCS1v15()
+            signed = private_key.sign(
+                auth_pack, padding=pad, algorithm=signature_hash()
+            )
+
+            encap_content_info_obj = self.EncapsulatedContentInfo_create(
+                krb5_asn1.id_pkinit_authData, auth_pack
+            )
+
+            subject_key_id = certificate.extensions.get_extension_for_oid(
+                x509.ExtensionOID.SUBJECT_KEY_IDENTIFIER
+            )
+            signer_identifier = self.SignerIdentifier_create(
+                subject_key_id=subject_key_id.value.digest
+            )
+
+            signer_info = self.SignerInfo_create(
+                signer_identifier,
+                signature_algorithm_id,
+                signature_algorithm_id,
+                signed,
+                signed_attrs=[
+                    # Note: these attributes are optional.
+                    krb5_asn1.id_pkinit_authData,
+                    krb5_asn1.id_messageDigest,
+                ],
+            )
+
+            encoded_cert = certificate.public_bytes(serialization.Encoding.DER)
+            decoded_cert = self.der_decode(
+                encoded_cert, asn1Spec=krb5_asn1.CertificateChoices()
+            )
+
+            signed_auth_pack = self.SignedData_create(
+                [signature_algorithm_id],
+                encap_content_info_obj,
+                signer_infos=[signer_info],
+                certificates=[decoded_cert],
+                crls=None,
+            )
+
+            signed_auth_pack = self.der_encode(
+                signed_auth_pack, asn1Spec=krb5_asn1.SignedData()
+            )
+
+            pk_as_req = self.PK_AS_REQ_create(signed_auth_pack)
+
+            pa_type = PADATA_PK_AS_REQ
+            padata = [self.PA_DATA_create(pa_type, pk_as_req)]
+
+            return padata, req_body
+
+        user_name = creds.get_username()
+        cname = self.PrincipalName_create(
+            name_type=NT_PRINCIPAL, names=user_name.split("/")
+        )
+
+        target_name = target_creds.get_username()
+        target_realm = target_creds.get_realm()
+
+        target_sname = self.PrincipalName_create(
+            name_type=NT_PRINCIPAL, names=["host", target_name[:-1]]
+        )
+        expected_sname = self.PrincipalName_create(
+            name_type=NT_PRINCIPAL, names=[target_name]
+        )
+
+        if expect_error:
+            check_error_fn = self.generic_check_kdc_error
+            check_rep_fn = None
+
+            expected_sname = target_sname
+        else:
+            check_error_fn = None
+            check_rep_fn = self.generic_check_kdc_rep
+
+        kdc_options = "forwardable,renewable,canonicalize,renewable-ok"
+        kdc_options = krb5_asn1.KDCOptions(kdc_options)
+
+        ticket_decryption_key = self.TicketDecryptionKey_from_creds(target_creds)
+
+        expected_groups = None
+        unexpected_groups = {
+            (
+                security.SID_FRESH_PUBLIC_KEY_IDENTITY,
+                SidType.EXTRA_SID,
+                security.SE_GROUP_DEFAULT_FLAGS,
+            )
+        }
+
+        kdc_exchange_dict = self.as_exchange_dict(
+            creds=creds,
+            client_cert=certificate,
+            expected_crealm=creds.get_realm(),
+            expected_cname=cname,
+            expected_srealm=target_realm,
+            expected_sname=expected_sname,
+            expected_supported_etypes=target_creds.tgs_supported_enctypes,
+            expected_groups=expected_groups,
+            unexpected_groups=unexpected_groups,
+            ticket_decryption_key=ticket_decryption_key,
+            generate_padata_fn=generate_pk_padata,
+            check_error_fn=check_error_fn,
+            check_rep_fn=check_rep_fn,
+            check_kdc_private_fn=self.generic_check_kdc_private,
+            expected_error_mode=expect_error,
+            expected_salt=creds.get_salt(),
+            preauth_key=preauth_key,
+            kdc_options=str(kdc_options),
+            using_pkinit=PkInit.PUBLIC_KEY,
+            pk_nonce=pk_nonce,
+            expect_edata=False,
+            expect_matching_nt_hash_in_pac=True,
+        )
+
+        till = self.get_KerberosTime(offset=36000)
+
+        etypes = (
+            kcrypto.Enctype.AES256,
+            kcrypto.Enctype.RC4,
+        )
+        # DES-EDE3-CBC is required for public-key PK-INIT to work on Windows.
+        etypes += (DES_EDE3_CBC,)
+
+        rep = self._generic_kdc_exchange(
+            kdc_exchange_dict,
+            cname=cname,
+            realm=target_realm,
+            sname=target_sname,
+            till_time=till,
+            etypes=etypes,
+        )
+        if expect_error:
+            self.check_error_rep(rep, expect_error)
+            return None
+
+        self.check_as_reply(rep)
+        return kdc_exchange_dict
+
+
+if __name__ == "__main__":
+    global_asn1_print = False
+    global_hexdump = False
+    import unittest
+
+    unittest.main()
index ab2d79d7114edb6ee29eafd4ef5e4ce84921471b..63637c774a261e208b9f5e68102de7e4abd4d211 100644 (file)
 
 # We currently don't send referrals for LDAP modify of non-replicated attrs
 ^samba4.ldap.rodc.python\(rodc\).__main__.RodcTests.test_modify_nonreplicated.*
+^samba.tests.krb5.key_trust_tests.*
index f44ce20f97e80f582e8df47e9ddfbdbe4fe8e162..2b590553fed5f849bcbfcaf77f6b42f7547b429d 100644 (file)
@@ -14,3 +14,7 @@
 # with the last password, see https://bugzilla.samba.org/show_bug.cgi?id=14054
 #
 ^samba4.ldap.login_basics.python.*.__main__.BasicUserAuthTests.test_login_basics_krb5
+#
+# MIT does not currently support key trust logons.
+#
+^samba.tests.krb5.key_trust_tests.*
index ffbee210994e99b20756cb1d3b9b401f6803d648..576785f7b83191bd7c78ffde47fedd856d10b4ae 100755 (executable)
@@ -2148,6 +2148,10 @@ planoldpythontestsuite(
     'ad_dc',
     'samba.tests.krb5.pkinit_tests',
     environ=krb5_environ)
+planoldpythontestsuite(
+    'ad_dc',
+    'samba.tests.krb5.key_trust_tests',
+    environ=krb5_environ)
 planoldpythontestsuite(
     'ad_dc',
     'samba.tests.krb5.conditional_ace_tests',