]> git.ipfire.org Git - thirdparty/samba.git/commitdiff
python/samba/krb5: Add test for password rotation on UF_SMARCARD_REQUIRED accounts
authorAndrew Bartlett <abartlet@samba.org>
Tue, 2 Apr 2024 21:53:11 +0000 (10:53 +1300)
committerAndrew Bartlett <abartlet@samba.org>
Mon, 10 Jun 2024 04:27:30 +0000 (04:27 +0000)
This demonstrates behaviour against a server presumed to be in FL 2016
what the impact of the msDS-ExpirePasswordsOnSmartCardOnlyAccounts
attribute is.

Signed-off-by: Andrew Bartlett <abartlet@samba.org>
Reviewed-by: Jo Sutton <josutton@catalyst.net.nz>
python/samba/tests/krb5/pkinit_tests.py
python/samba/tests/krb5/raw_testcase.py
selftest/knownfail_heimdal_kdc
selftest/knownfail_mit_kdc_1_20

index eff06ff5d208591c89b23dc48ceb5a9b53535e3b..7a1435ceb7e2115e6f4d3465d787d3b8403d756d 100755 (executable)
@@ -67,6 +67,23 @@ SidType = RawKerberosTest.SidType
 global_asn1_print = False
 global_hexdump = False
 
+def set_ExpirePasswordsOnSmartCardOnlyAccounts(samdb, val):
+    msg = ldb.Message()
+    msg.dn = samdb.get_default_basedn()
+
+    # Allow val to be True, False, strings or message elements
+    if val is True:
+        val = "TRUE"
+    elif val is False:
+        val = "FALSE"
+    elif val is None:
+        val = []
+
+    msg["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"] \
+        = ldb.MessageElement(val,
+                             ldb.FLAG_MOD_REPLACE,
+                             "msDS-ExpirePasswordsOnSmartCardOnlyAccounts")
+    samdb.modify(msg)
 
 class PkInitTests(KDCBaseTest):
     @classmethod
@@ -589,6 +606,59 @@ class PkInitTests(KDCBaseTest):
                             logon_type=netlogon.NetlogonNetworkInformation,
                             expect_error=ntstatus.NT_STATUS_WRONG_PASSWORD)
 
+    def _test_samlogon_smartcard_required_expired(self, smartcard_pw_expire):
+        """Test SamLogon with an account set to smartcard login required.  No actual PK-INIT in this test."""
+        samdb = self.get_samdb()
+        msgs = samdb.search(base=samdb.get_default_basedn(),
+                            scope=ldb.SCOPE_BASE,
+                            attrs=["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"])
+        msg = msgs[0]
+
+        try:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = msg["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"]
+        except KeyError:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = None
+
+        self.addCleanup(set_ExpirePasswordsOnSmartCardOnlyAccounts,
+                        samdb, old_ExpirePasswordsOnSmartCardOnlyAccounts)
+
+        # Enable auto-rotation for this test
+        set_ExpirePasswordsOnSmartCardOnlyAccounts(samdb, smartcard_pw_expire)
+
+        client_creds = self._get_creds(smartcard_required=True)
+
+        client_creds.set_kerberos_state(credentials.AUTO_USE_KERBEROS)
+
+        msg = ldb.Message()
+        msg.dn = client_creds.get_dn()
+
+        # Ideally we would set this to a time just long enough for the
+        # password to expire, but we are unable to do that.
+        #
+        # 0 means "must change on first login"
+        msg["pwdLastSet"] = \
+            ldb.MessageElement(str(0),
+                               ldb.FLAG_MOD_REPLACE,
+                               "pwdLastSet")
+        samdb.modify(msg)
+
+        # This shows that the magic rotation behaviour is not
+        # triggered in SamLogon
+        self._test_samlogon(
+            creds=client_creds,
+            logon_type=netlogon.NetlogonInteractiveInformation,
+            expect_error=ntstatus.NT_STATUS_SMARTCARD_LOGON_REQUIRED)
+
+        self._test_samlogon(creds=client_creds,
+                            logon_type=netlogon.NetlogonNetworkInformation,
+                            expect_error=ntstatus.NT_STATUS_WRONG_PASSWORD)
+
+    def test_samlogon_smartcard_required_expired(self):
+        self._test_samlogon_smartcard_required_expired(True)
+
+    def test_samlogon_smartcard_required_expired_disabled(self):
+        self._test_samlogon_smartcard_required_expired(False)
+
     def test_pkinit_ntlm_from_pac(self):
         """Test public-key PK-INIT to get an NT hash and confirm NTLM
            authentication is possible with it."""
@@ -630,8 +700,11 @@ class PkInitTests(KDCBaseTest):
 
         freshness_token = self.create_freshness_token()
 
+        # The hash will not match as UF_SMARTCARD_REQUIRED at creation
+        # time make the password random
         kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
-                                             freshness_token=freshness_token)
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=False)
         nt_hash_from_pac = kdc_exchange_dict['nt_hash_from_pac']
 
         client_creds.set_nt_hash(nt_hash_from_pac,
@@ -659,22 +732,23 @@ class PkInitTests(KDCBaseTest):
     def test_pkinit_ntlm_from_pac_must_change_now(self):
         """Test public-key PK-INIT to get an NT hash and confirm NTLM
            authentication is possible with it."""
+        samdb = self.get_samdb()
+
         client_creds = self._get_creds()
         client_creds.set_kerberos_state(credentials.AUTO_USE_KERBEROS)
 
-        msg = ldb.Message()
-        msg.dn = client_creds.get_dn()
+        mod_msg = ldb.Message()
+        mod_msg.dn = client_creds.get_dn()
 
         # Ideally we would set this to a time just long enough for the
-        # password to expire, but we are unable to do that.
+        # password to expire, but this is good enough
         #
         # 0 means "must change on first login"
-        msg["pwdLastSet"] = \
+        mod_msg["pwdLastSet"] = \
             ldb.MessageElement(str(0),
                                ldb.FLAG_MOD_REPLACE,
                                "pwdLastSet")
-        samdb = self.get_samdb()
-        samdb.modify(msg)
+        samdb.modify(mod_msg)
 
         krbtgt_creds = self.get_krbtgt_creds()
 
@@ -707,9 +781,29 @@ class PkInitTests(KDCBaseTest):
                             logon_type=netlogon.NetlogonNetworkInformation,
                             expect_error=ntstatus.NT_STATUS_PASSWORD_MUST_CHANGE)
 
-    def test_pkinit_ntlm_from_pac_smartcard_required_must_change_now(self):
+    def _test_pkinit_ntlm_from_pac_smartcard_required_must_change_now(self, smartcard_pw_expire):
         """Test public-key PK-INIT to get the user's NT hash for an account
-           that is restricted by UF_SMARTCARD_REQUIRED."""
+           that is restricted by UF_SMARTCARD_REQUIRED.
+
+        We test with both modes for the 2016FL msDS-ExpirePasswordsOnSmartCardOnlyAccounts behaviour"""
+
+        samdb = self.get_samdb()
+        msgs = samdb.search(base=samdb.get_default_basedn(),
+                            scope=ldb.SCOPE_BASE,
+                            attrs=["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"])
+        msg = msgs[0]
+
+        try:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = msg["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"]
+        except KeyError:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = None
+
+        self.addCleanup(set_ExpirePasswordsOnSmartCardOnlyAccounts,
+                        samdb, old_ExpirePasswordsOnSmartCardOnlyAccounts)
+
+        # Enable auto-rotation for this test
+        set_ExpirePasswordsOnSmartCardOnlyAccounts(samdb, smartcard_pw_expire)
+
         client_creds = self._get_creds(smartcard_required=True)
         client_creds.set_kerberos_state(credentials.AUTO_USE_KERBEROS)
 
@@ -717,42 +811,129 @@ class PkInitTests(KDCBaseTest):
 
         freshness_token = self.create_freshness_token()
 
-        samdb = self.get_samdb()
-
+        # The hash will not match as UF_SMARTCARD_REQUIRED at creation
+        # time make the password random
         kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
-                                             freshness_token=freshness_token)
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=False)
         nt_hash_from_pac = kdc_exchange_dict['nt_hash_from_pac']
 
-        msg = ldb.Message()
-        msg.dn = client_creds.get_dn()
+        client_creds.set_nt_hash(nt_hash_from_pac,
+                                 credentials.SPECIFIED)
+
+        mod_msg = ldb.Message()
+        mod_msg.dn = client_creds.get_dn()
 
         # Ideally we would set this to a time just long enough for the
-        # password to expire, but we are unable to do that.
+        # password to expire, but this is good enough
         #
         # 0 means "must change on first login"
-        msg["pwdLastSet"] = \
+        mod_msg["pwdLastSet"] = \
             ldb.MessageElement(str(0),
                                ldb.FLAG_MOD_REPLACE,
                                "pwdLastSet")
-        samdb.modify(msg)
+        samdb.modify(mod_msg)
+
+        # pwdLastSet has magic set properties, but this still sticks
+        # to zero.  We assert this so that we can be sure of the
+        # remaining checks
+        res = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        self.assertEqual(int(res[0]["pwdLastSet"][0]), 0)
+
+        # Interactive SamLogon will fail, but with
+        # SMARTCARD_LOGON_REQUIRED not password expired
+        self._test_samlogon(
+            creds=client_creds,
+            logon_type=netlogon.NetlogonInteractiveInformation,
+            expect_error=ntstatus.NT_STATUS_SMARTCARD_LOGON_REQUIRED)
+
+        # The password should not have changed yet as we have not
+        # touched the KDC so far
+        res = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        self.assertEqual(int(res[0]["pwdLastSet"][0]), 0)
+
+        if smartcard_pw_expire:
+            # msDS-ExpirePasswordsOnSmartCardOnlyAccounts=TRUE
+            #
+            # Try NTLM (Network SamLogon), this show that password expiry
+            # is enforced for UF_SMARTCARD_REQUIRED
+            self._test_samlogon(creds=client_creds,
+                                logon_type=netlogon.NetlogonNetworkInformation,
+                                expect_error=ntstatus.NT_STATUS_PASSWORD_MUST_CHANGE)
+        else:
+            # msDS-ExpirePasswordsOnSmartCardOnlyAccounts=FALSE
+            #
+            # Try NTLM (Network SamLogon), this show that password expiry
+            # is not enforced for UF_SMARTCARD_REQUIRED
+            self._test_samlogon(creds=client_creds,
+                                logon_type=netlogon.NetlogonNetworkInformation)
+
+        # The password should not have changed yet as we have not
+        # touched the KDC so far
+        res = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        self.assertEqual(int(res[0]["pwdLastSet"][0]), 0)
+
+        # password-based AS-REQ will fail, but with
+        # SMARTCARD_LOGON_REQUIRED not password expired.
+        #
+        # But it will rotate the PW.
+        self._as_req(client_creds,
+                     krbtgt_creds,
+                     expect_error=KDC_ERR_POLICY,
+                     expect_edata=True,
+                     expect_status=True,
+                     expected_status=ntstatus.NT_STATUS_SMARTCARD_LOGON_REQUIRED,
+                     send_enc_ts=True)
+
+        res = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        if smartcard_pw_expire:
+            # The password should have changed as it was expired and the
+            # KDC is set up to change expired passwords to keep the
+            # smart-card logins working and the keys fresh
+            self.assertGreater(int(res[0]["pwdLastSet"][0]), 0)
+        else:
+            self.assertEqual(int(res[0]["pwdLastSet"][0]), 0)
 
         kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
-                                             freshness_token=freshness_token)
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=not smartcard_pw_expire)
         nt_hash_from_pac2 = kdc_exchange_dict['nt_hash_from_pac']
 
-        self.assertNotEqual(nt_hash_from_pac.hash, nt_hash_from_pac2.hash)
+        if smartcard_pw_expire:
+            self.assertNotEqual(nt_hash_from_pac.hash, nt_hash_from_pac2.hash)
+        else:
+            self.assertEqual(nt_hash_from_pac.hash, nt_hash_from_pac2.hash)
 
-        # The password should have changed as it was expired and the
-        # DC is set up to change expired passwords to keep the
-        # smart-card logins working and the keys fresh
-        res = samdb.search(base=client_creds.get_dn(),
+        # The password will not have further changed, the not-PKINIT
+        # request will have triggered the rotation.
+        res2 = samdb.search(base=client_creds.get_dn(),
                            scope=ldb.SCOPE_BASE,
                            attrs=["pwdLastSet"])
-        self.assertNotEqual(res[0]["pwdLastSet"], 0)
+        self.assertEqual(res[0]["pwdLastSet"], res2[0]["pwdLastSet"])
 
         client_creds.set_nt_hash(nt_hash_from_pac2,
                                  credentials.SPECIFIED)
 
+        # Password has not changed again, so we will continue to get the same NT hash
+        kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=True)
+
+        # The password will not have further changed, the earlier
+        # not-PKINIT request will have triggered the rotation.
+        res3 = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        self.assertEqual(res[0]["pwdLastSet"], res3[0]["pwdLastSet"])
+
         # password-based AS-REQ will fail
         self._as_req(client_creds,
                      krbtgt_creds,
@@ -773,6 +954,114 @@ class PkInitTests(KDCBaseTest):
         self._test_samlogon(creds=client_creds,
                             logon_type=netlogon.NetlogonNetworkInformation)
 
+    def test_pkinit_ntlm_from_pac_smartcard_required_must_change_now(self):
+        """Test public-key PK-INIT to get the user's NT hash for an account
+           that is restricted by UF_SMARTCARD_REQUIRED but is expired.
+
+           Verify that NT hash with SamLogon requests
+
+           This variant sets the enabling attribute for auto-rotation."""
+        self._test_pkinit_ntlm_from_pac_smartcard_required_must_change_now(True)
+
+    def test_pkinit_ntlm_from_pac_smartcard_required_must_change_now_rotate_disabled(self):
+        """Test public-key PK-INIT to get the user's NT hash for an account
+           that is restricted by UF_SMARTCARD_REQUIRED but is expired.
+
+           Verify that NT hash with SamLogon requests
+
+           This variant DISABLES the enabling attribute for auto-rotation."""
+        self._test_pkinit_ntlm_from_pac_smartcard_required_must_change_now(False)
+
+    def _test_pkinit_smartcard_required_must_change_now(self, smartcard_pw_expire):
+        """Test public-key PK-INIT to get the user's NT hash for an account
+           that is restricted by UF_SMARTCARD_REQUIRED.
+
+        We test with both modes for the 2016FL msDS-ExpirePasswordsOnSmartCardOnlyAccounts behaviour"""
+
+        samdb = self.get_samdb()
+        msgs = samdb.search(base=samdb.get_default_basedn(),
+                            scope=ldb.SCOPE_BASE,
+                            attrs=["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"])
+        msg = msgs[0]
+
+        try:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = msg["msDS-ExpirePasswordsOnSmartCardOnlyAccounts"]
+        except KeyError:
+            old_ExpirePasswordsOnSmartCardOnlyAccounts = None
+
+        self.addCleanup(set_ExpirePasswordsOnSmartCardOnlyAccounts,
+                        samdb, old_ExpirePasswordsOnSmartCardOnlyAccounts)
+
+        # Enable auto-rotation for this test
+        set_ExpirePasswordsOnSmartCardOnlyAccounts(samdb, smartcard_pw_expire)
+
+        client_creds = self._get_creds(smartcard_required=True)
+        client_creds.set_kerberos_state(credentials.AUTO_USE_KERBEROS)
+
+        krbtgt_creds = self.get_krbtgt_creds()
+
+        freshness_token = self.create_freshness_token()
+
+        kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=False)
+        nt_hash_from_pac = kdc_exchange_dict['nt_hash_from_pac']
+
+        mod_msg = ldb.Message()
+        mod_msg.dn = client_creds.get_dn()
+
+        # Ideally we would set this to a time just long enough for the
+        # password to expire, but this is good enough
+        #
+        # 0 means "must change on first login"
+        mod_msg["pwdLastSet"] = \
+            ldb.MessageElement(str(0),
+                               ldb.FLAG_MOD_REPLACE,
+                               "pwdLastSet")
+        samdb.modify(mod_msg)
+
+        # pwdLastSet has magic set properties, but this still sticks
+        # to zero.  We assert this so that we can be sure of the
+        # remaining checks
+        res = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        self.assertEqual(int(res[0]["pwdLastSet"][0]), 0)
+
+        kdc_exchange_dict = self._pkinit_req(client_creds, krbtgt_creds,
+                                             freshness_token=freshness_token,
+                                             expect_matching_nt_hash_in_pac=False)
+        nt_hash_from_pac2 = kdc_exchange_dict['nt_hash_from_pac']
+
+        if smartcard_pw_expire:
+            self.assertNotEqual(nt_hash_from_pac.hash, nt_hash_from_pac2.hash)
+        else:
+            self.assertEqual(nt_hash_from_pac.hash, nt_hash_from_pac2.hash)
+
+        # If expiry/rotation enabled, the password will have changed, the PKINIT
+        # request will have triggered the rotation.
+        res2 = samdb.search(base=client_creds.get_dn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=["pwdLastSet"])
+        if smartcard_pw_expire:
+            self.assertGreater(int(res2[0]["pwdLastSet"][0]), 0)
+        else:
+            self.assertEqual(int(res2[0]["pwdLastSet"][0]), 0)
+
+    def test_pkinit_smartcard_required_must_change_now(self):
+        """Test public-key PK-INIT to get the user's NT hash for an account
+           that is restricted by UF_SMARTCARD_REQUIRED but is expired.
+
+           This variant sets the enabling attribute for auto-rotation."""
+        self._test_pkinit_smartcard_required_must_change_now(True)
+
+    def test_pkinit_smartcard_required_must_change_now_rotate_disabled(self):
+        """Test public-key PK-INIT to get the user's NT hash for an account
+           that is restricted by UF_SMARTCARD_REQUIRED but is expired.
+
+           This variant DISABLES the enabling attribute for auto-rotation."""
+        self._test_pkinit_smartcard_required_must_change_now(False)
+
     def test_pkinit_kpasswd_change(self):
         """Test public-key PK-INIT to get an initial ticket to change the user's own password."""
         client_creds = self._get_creds()
@@ -1199,6 +1488,7 @@ class PkInitTests(KDCBaseTest):
                     certificate_signature=None,
                     freshness_token=None,
                     win2k_variant=False,
+                    expect_matching_nt_hash_in_pac=True,
                     target_sname=None
                     ):
         self.assertIsNot(using_pkinit, PkInit.NOT_USED)
@@ -1460,7 +1750,8 @@ class PkInitTests(KDCBaseTest):
             kdc_options=str(kdc_options),
             using_pkinit=using_pkinit,
             pk_nonce=pk_nonce,
-            expect_edata=expect_edata)
+            expect_edata=expect_edata,
+            expect_matching_nt_hash_in_pac=expect_matching_nt_hash_in_pac)
 
         till = self.get_KerberosTime(offset=36000)
 
index 75ba6d44d485f1d51b1fb3ddf39acfd26a48c714..cb033472069e9309dc33e7f5a22ff8b85d996433 100644 (file)
@@ -3108,6 +3108,7 @@ class RawKerberosTest(TestCase):
                          expect_resource_groups_flag=None,
                          expected_device_groups=None,
                          expected_extra_pac_buffers=None,
+                         expect_matching_nt_hash_in_pac=None,
                          to_rodc=False):
         if expected_error_mode == 0:
             expected_error_mode = ()
@@ -3188,6 +3189,7 @@ class RawKerberosTest(TestCase):
             'expect_resource_groups_flag': expect_resource_groups_flag,
             'expected_device_groups': expected_device_groups,
             'expected_extra_pac_buffers': expected_extra_pac_buffers,
+            'expect_matching_nt_hash_in_pac': expect_matching_nt_hash_in_pac,
             'to_rodc': to_rodc
         }
         if callback_dict is None:
@@ -4781,10 +4783,10 @@ class RawKerberosTest(TestCase):
 
                 creds = kdc_exchange_dict['creds']
                 nt_password = bytes(ntlm_package.nt_password.hash)
-                if creds.user_account_control & UF_SMARTCARD_REQUIRED:
-                    self.assertNotEqual(creds.get_nt_hash(), nt_password)
-                else:
+                if kdc_exchange_dict['expect_matching_nt_hash_in_pac']:
                     self.assertEqual(creds.get_nt_hash(), nt_password)
+                else:
+                    self.assertNotEqual(creds.get_nt_hash(), nt_password)
 
                 kdc_exchange_dict['nt_hash_from_pac'] = ntlm_package.nt_password
 
index 1395d9b44675dfa596e625d408b7c82c4e64856e..69fb4eb7bdcc52e6055a14e9f4a0e3c2a077b2bb 100644 (file)
@@ -75,6 +75,7 @@
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_ntlm_from_pac_must_change_now
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_ntlm_from_pac_smartcard_required
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_samlogon_smartcard_required
+^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_smartcard_required_must_change_now
 #
 # Windows 2000 PK-INIT tests
 #
index 74339056213ae98bd0a6f989011aa0da4cef6559..d01a49041a965fc44d405ad96a9d6a38f0c9c297 100644 (file)
@@ -36,6 +36,7 @@
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_ntlm_from_pac
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_kpasswd_change
 ^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_samlogon_smartcard_required
+^samba.tests.krb5.pkinit_tests.samba.tests.krb5.pkinit_tests.PkInitTests.test_pkinit_smartcard_required_must_change_now
 #
 # PK-INIT Freshness tests
 #