From 856df6f849502e5cd93ce319d572e4d835ef79fb Mon Sep 17 00:00:00 2001 From: zarganum Date: Mon, 29 Jul 2024 19:31:48 +0000 Subject: [PATCH 01/18] password change draft --- setup.py | 1 + src/krb5/__init__.py | 9 +++ src/krb5/_change_password.pyi | 62 ++++++++++++++++ src/krb5/_change_password.pyx | 130 ++++++++++++++++++++++++++++++++++ src/krb5/_krb5_types.pxd | 21 +++++- tests/conftest.py | 2 +- tests/test_changepw.py | 22 ++++++ 7 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/krb5/_change_password.pyi create mode 100644 src/krb5/_change_password.pyx create mode 100644 tests/test_changepw.py diff --git a/setup.py b/setup.py index 4911fc7..460e56b 100755 --- a/setup.py +++ b/setup.py @@ -220,6 +220,7 @@ def get_krb5_lib_path( "context", ("context_mit", "krb5_init_secure_context"), "creds", + ("change_password", "krb5_set_password"), ("creds_marshal_mit", "krb5_marshal_credentials"), ("creds_mit", "krb5_get_etype_info"), "creds_opt", diff --git a/src/krb5/__init__.py b/src/krb5/__init__.py index a98fcbf..5993c4b 100644 --- a/src/krb5/__init__.py +++ b/src/krb5/__init__.py @@ -188,6 +188,15 @@ __all__.append("unmarshal_credentials") +try: + from krb5._change_password import set_password, set_password_using_ccache +except ImportError: + pass +else: + __all__.append("set_password") + __all__.append("set_password_using_ccache") + + try: from krb5._ccache_match import cc_cache_match except ImportError: diff --git a/src/krb5/_change_password.pyi b/src/krb5/_change_password.pyi new file mode 100644 index 0000000..12d4ad8 --- /dev/null +++ b/src/krb5/_change_password.pyi @@ -0,0 +1,62 @@ +import typing + +from krb5._ccache import CCache +from krb5._context import Context +from krb5._creds import Creds +from krb5._principal import Principal + +def set_password( + context: Context, + creds: Creds, + newpw: bytes, + change_password_for: typing.Optional[Principal], +) -> bytes: + """Set a password for a principal using specified credentials. + + + This function implements the set password operation of ``RFC 3244``, + for interoperability with Microsoft Windows implementations. + It uses the credentials creds to set the password newpw for the + principal change_password_for. + If change_password_for is `None`, the password is set for the principal + owning creds. If change_password_for is not `None`, the change is + performed on the specified principal. + + This is only present when compiled against MIT 1.7 or newer. + + Args: + context: Krb5 context. + creds: Credentials for kadmin/changepw service. + newpw: New password. + change_password_for: `None` or the principal to set the password for. + + Returns: + bytes: Data returned from the remote system.""" + +def set_password_using_ccache( + context: Context, + ccache: CCache, + newpw: bytes, + change_password_for: typing.Optional[Principal], +) -> bytes: + """Set a password for a principal using cached credentials. + + + This function implements the set password operation of ``RFC 3244``, + for interoperability with Microsoft Windows implementations. + It uses the cached credentials from ccache to set the password newpw for + the principal change_password_for. + If change_password_for is `None`, the change is performed on the default + principal in ccache. If change_password_for is not `None`, the change is + performed on the specified principal. + + This is only present when compiled against MIT 1.7 or newer. + + Args: + context: Krb5 context. + creds: Credentials to serialize. + newpw: The new password. + change_password_for: `None` or the principal to set the password for. + + Returns: + bytes: Data returned from the remote system.""" diff --git a/src/krb5/_change_password.pyx b/src/krb5/_change_password.pyx new file mode 100644 index 0000000..465f8a1 --- /dev/null +++ b/src/krb5/_change_password.pyx @@ -0,0 +1,130 @@ +# Support for Microsoft set/change password was added in MIT 1.7 + +import collections +import typing + +from krb5._exceptions import Krb5Error + +from krb5._ccache cimport CCache +from krb5._context cimport Context +from krb5._creds cimport Creds +from krb5._krb5_types cimport * +from krb5._principal cimport Principal + + +cdef extern from "python_krb5.h": + krb5_error_code krb5_set_password( + krb5_context context, + krb5_creds *creds, + const char *newpw, + krb5_principal change_password_for, + int *result_code, + krb5_data *result_code_string, + krb5_data *result_string + ) nogil + + krb5_error_code krb5_set_password_using_ccache( + krb5_context context, + krb5_ccache ccache, + const char *newpw, + krb5_principal change_password_for, + int *result_code, + krb5_data *result_code_string, + krb5_data *result_string + ) nogil + +def set_password( + Context context not None, + Creds creds not None, + const unsigned char[:] newpw not None, + change_password_for: typing.Optional[Principal] = None, +) -> bytes: + cdef krb5_error_code err = 0 + cdef int result_code + cdef krb5_data result_code_string + cdef krb5_data result_string + cdef char *newpw_ptr = &newpw[0] + cdef krb5_principal change_password_for_ptr = NULL + cdef size_t length + cdef char *value + + pykrb5_init_krb5_data(&result_code_string) + pykrb5_init_krb5_data(&result_string) + + if change_password_for is not None: + change_password_for_ptr = change_password_for.raw + + try: + err = krb5_set_password( + context.raw, + creds.get_pointer(), + newpw_ptr, + change_password_for_ptr, + &result_code, + &result_code_string, + &result_string + ) + + if err: + raise Krb5Error(context, err) + + pykrb5_get_krb5_data(&result_code_string, &length, &value) + + if length == 0: + data_bytes = b"" + else: + data_bytes = value[:length] + + return data_bytes + + finally: + pykrb5_free_data_contents(context.raw, &result_code_string) + pykrb5_free_data_contents(context.raw, &result_string) + +def set_password_using_ccache( + Context context not None, + CCache ccache not None, + const unsigned char[:] newpw not None, + change_password_for: typing.Optional[Principal] = None, +) -> bytes: + cdef krb5_error_code err = 0 + cdef int result_code + cdef krb5_data result_code_string + cdef krb5_data result_string + cdef char *newpw_ptr = &newpw[0] + cdef krb5_principal change_password_for_ptr = NULL + cdef size_t length + cdef char *value + + pykrb5_init_krb5_data(&result_code_string) + pykrb5_init_krb5_data(&result_string) + + if change_password_for is not None: + change_password_for_ptr = change_password_for.raw + + try: + err = krb5_set_password_using_ccache( + context.raw, + ccache.raw, + newpw_ptr, + change_password_for_ptr, + &result_code, + &result_code_string, + &result_string + ) + + if err: + raise Krb5Error(context, err) + + pykrb5_get_krb5_data(&result_string, &length, &value) + + if length == 0: + data_bytes = b"" + else: + data_bytes = value[:length] + + return data_bytes + + finally: + pykrb5_free_data_contents(context.raw, &result_code_string) + pykrb5_free_data_contents(context.raw, &result_string) diff --git a/src/krb5/_krb5_types.pxd b/src/krb5/_krb5_types.pxd index fcc9ec4..4309a34 100644 --- a/src/krb5/_krb5_types.pxd +++ b/src/krb5/_krb5_types.pxd @@ -3,9 +3,24 @@ from libc.stdint cimport int32_t, uint8_t, uint32_t - cdef extern from "python_krb5.h": """ + // The Heimdal and MIT krb5 libraries have different implementations of the krb5_data struct. + // MIT uses a struct with a member magic of -1760647422L + // Heimdal uses a struct without a magic + void pykrb5_init_krb5_data( + krb5_data *data + ) + { +#if defined(HEIMDAL_XFREE) + krb5_data_zero(data); +#else + data->magic = KV5M_DATA; + data->length = 0; + data->data = NULL; +#endif + } + // The structures are different so cannot be explicitly defined in Cython. Use inline C to set the structs elements // by name. void pykrb5_set_krb5_data( @@ -121,6 +136,10 @@ cdef extern from "python_krb5.h": krb5_prompt prompts[], ) + void pykrb5_init_krb5_data( + krb5_data *data, + ) nogil + void pykrb5_set_krb5_data( krb5_data *data, size_t length, diff --git a/tests/conftest.py b/tests/conftest.py index c3d0919..411386d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ # This could be extensive to do per function so just do it once and share that @pytest.fixture(scope="session") def realm() -> typing.Iterator[k5test.K5Realm]: - test_realm = k5test.K5Realm() + test_realm = k5test.K5Realm(start_kadmind=True) try: original_env: typing.Dict[str, typing.Optional[str]] = {} for k in test_realm.env.keys(): diff --git a/tests/test_changepw.py b/tests/test_changepw.py new file mode 100644 index 0000000..4149033 --- /dev/null +++ b/tests/test_changepw.py @@ -0,0 +1,22 @@ +import typing + +import k5test +import pytest + +import krb5 + + +@pytest.mark.requires_api("set_password") +def test_set_password(realm: k5test.K5Realm) -> None: + ctx = krb5.init_context() + princ = krb5.parse_name_flags(ctx, realm.user_princ.encode()) + opt = krb5.get_init_creds_opt_alloc(ctx) + creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("user").encode()) + assert isinstance(creds, krb5.Creds) + + newpw = realm.password("user").encode() + result = krb5.set_password(ctx, creds, newpw, princ) + raise ValueError(result) + + creds = krb5.get_init_creds_password(ctx, princ, opt, newpw) + assert isinstance(creds, krb5.Creds) From 439417a4a4939539853729c81f4e9cb658a9a095 Mon Sep 17 00:00:00 2001 From: zarganum Date: Thu, 22 Aug 2024 17:58:21 +0000 Subject: [PATCH 02/18] test password expiration --- tests/conftest.py | 5 ++++- tests/test_creds.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index c3d0919..30c021c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,10 @@ # This could be extensive to do per function so just do it once and share that @pytest.fixture(scope="session") def realm() -> typing.Iterator[k5test.K5Realm]: - test_realm = k5test.K5Realm() + test_realm = k5test.K5Realm(start_kadmind=True) + + test_realm.run_kadminl(["addprinc", "-pw", test_realm.password("user"), "+needchange", "userexp"]) + try: original_env: typing.Dict[str, typing.Optional[str]] = {} for k in test_realm.env.keys(): diff --git a/tests/test_creds.py b/tests/test_creds.py index 1cb5473..913b7ae 100644 --- a/tests/test_creds.py +++ b/tests/test_creds.py @@ -101,6 +101,16 @@ def test_get_init_creds_password_prompt(realm: k5test.K5Realm) -> None: assert prompter.prompt_calls[0] == (expected, True) +def test_get_init_creds_password_prompt_expired(realm: k5test.K5Realm) -> None: + ctx = krb5.init_context() + princ = krb5.parse_name_flags(ctx, f"userexp@{realm.realm}".encode()) + opt = krb5.get_init_creds_opt_alloc(ctx) + + with pytest.raises(krb5.Krb5Error) as exc: + krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("user").encode()) + assert exc.value.err_code == -1765328361 + + def test_get_init_creds_password_prompt_failure(realm: k5test.K5Realm) -> None: ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, realm.user_princ.encode()) From 064ec1f2b9da3d1b60b2692ea8afa68f5b123f35 Mon Sep 17 00:00:00 2001 From: zarganum Date: Thu, 22 Aug 2024 19:21:46 +0000 Subject: [PATCH 03/18] test expired creds --- tests/test_changepw.py | 10 +++++++--- tests/test_creds.py | 10 ---------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index 4149033..ae468e6 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -9,14 +9,18 @@ @pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: ctx = krb5.init_context() - princ = krb5.parse_name_flags(ctx, realm.user_princ.encode()) + princ = krb5.parse_name_flags(ctx, f"userexp@{realm.realm}".encode()) opt = krb5.get_init_creds_opt_alloc(ctx) - creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("user").encode()) + + with pytest.raises(krb5.Krb5Error) as exc: + krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("user").encode()) + assert exc.value.err_code == -1765328361 + + creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("user").encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) newpw = realm.password("user").encode() result = krb5.set_password(ctx, creds, newpw, princ) - raise ValueError(result) creds = krb5.get_init_creds_password(ctx, princ, opt, newpw) assert isinstance(creds, krb5.Creds) diff --git a/tests/test_creds.py b/tests/test_creds.py index 913b7ae..1cb5473 100644 --- a/tests/test_creds.py +++ b/tests/test_creds.py @@ -101,16 +101,6 @@ def test_get_init_creds_password_prompt(realm: k5test.K5Realm) -> None: assert prompter.prompt_calls[0] == (expected, True) -def test_get_init_creds_password_prompt_expired(realm: k5test.K5Realm) -> None: - ctx = krb5.init_context() - princ = krb5.parse_name_flags(ctx, f"userexp@{realm.realm}".encode()) - opt = krb5.get_init_creds_opt_alloc(ctx) - - with pytest.raises(krb5.Krb5Error) as exc: - krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("user").encode()) - assert exc.value.err_code == -1765328361 - - def test_get_init_creds_password_prompt_failure(realm: k5test.K5Realm) -> None: ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, realm.user_princ.encode()) From 2c89e6e74af9a824ccc5ff2cffb629554e8179ed Mon Sep 17 00:00:00 2001 From: zarganum Date: Thu, 22 Aug 2024 19:40:27 +0000 Subject: [PATCH 04/18] localize changepw test --- tests/conftest.py | 4 +--- tests/test_changepw.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 30c021c..4f90488 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,9 +13,7 @@ # This could be extensive to do per function so just do it once and share that @pytest.fixture(scope="session") def realm() -> typing.Iterator[k5test.K5Realm]: - test_realm = k5test.K5Realm(start_kadmind=True) - - test_realm.run_kadminl(["addprinc", "-pw", test_realm.password("user"), "+needchange", "userexp"]) + test_realm = k5test.K5Realm() try: original_env: typing.Dict[str, typing.Optional[str]] = {} diff --git a/tests/test_changepw.py b/tests/test_changepw.py index ae468e6..1fe2ce7 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -8,15 +8,21 @@ @pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: + realm.start_kadmind() + + princ_name = "userexp@" + realm.realm + + realm.run_kadminl(["addprinc", "-pw", realm.password("userexp"), "+needchange", princ_name]) + ctx = krb5.init_context() - princ = krb5.parse_name_flags(ctx, f"userexp@{realm.realm}".encode()) + princ = krb5.parse_name_flags(ctx, princ_name.encode()) opt = krb5.get_init_creds_opt_alloc(ctx) with pytest.raises(krb5.Krb5Error) as exc: - krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("user").encode()) - assert exc.value.err_code == -1765328361 + krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("userexp").encode()) + assert exc.value.err_code == -1765328361 #KRB5KDC_ERR_KEY_EXP - creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("user").encode(), in_tkt_service=b"kadmin/changepw") + creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("userexp").encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) newpw = realm.password("user").encode() @@ -24,3 +30,5 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, newpw) assert isinstance(creds, krb5.Creds) + + realm.stop_kadmind() From 5232ecc47cb599cff4b84f393f1b4babf365737d Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 07:15:30 +0000 Subject: [PATCH 05/18] pykrb5_set_krb5_data enforce MIT magic --- src/krb5/_krb5_types.pxd | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/krb5/_krb5_types.pxd b/src/krb5/_krb5_types.pxd index 4309a34..5e05c04 100644 --- a/src/krb5/_krb5_types.pxd +++ b/src/krb5/_krb5_types.pxd @@ -29,6 +29,9 @@ cdef extern from "python_krb5.h": char *value ) { +#if !defined(HEIMDAL_XFREE) + data->magic = KV5M_DATA; +#endif data->length = length; data->data = value; } From c1774b902af59bf90cce8f72aaf7c174f1640447 Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 07:16:01 +0000 Subject: [PATCH 06/18] set_password extended result and test --- src/krb5/_change_password.pyi | 22 +++++++-- src/krb5/_change_password.pyx | 46 ++++++++++++++---- tests/test_changepw.py | 90 +++++++++++++++++++++++++++++++---- 3 files changed, 135 insertions(+), 23 deletions(-) diff --git a/src/krb5/_change_password.pyi b/src/krb5/_change_password.pyi index 12d4ad8..9e76528 100644 --- a/src/krb5/_change_password.pyi +++ b/src/krb5/_change_password.pyi @@ -10,7 +10,7 @@ def set_password( creds: Creds, newpw: bytes, change_password_for: typing.Optional[Principal], -) -> bytes: +) -> typing.Tuple[int, bytes, bytes]: """Set a password for a principal using specified credentials. @@ -22,6 +22,10 @@ def set_password( owning creds. If change_password_for is not `None`, the change is performed on the specified principal. + Note: to change the expired password for owner, obtain the owner creds using + get_init_creds_password() with in_tkt_service set to "kadmin/changepw" and + then use those creds to set the new password. + This is only present when compiled against MIT 1.7 or newer. Args: @@ -31,14 +35,18 @@ def set_password( change_password_for: `None` or the principal to set the password for. Returns: - bytes: Data returned from the remote system.""" + Tuple (result code, result code string, server response): + The non-zero result code means error. + The server response may contain additional information about + password policy violations or other errors. + """ def set_password_using_ccache( context: Context, ccache: CCache, newpw: bytes, change_password_for: typing.Optional[Principal], -) -> bytes: +) -> typing.Tuple[int, bytes, bytes]: """Set a password for a principal using cached credentials. @@ -54,9 +62,13 @@ def set_password_using_ccache( Args: context: Krb5 context. - creds: Credentials to serialize. + ccache: Credential cache. newpw: The new password. change_password_for: `None` or the principal to set the password for. Returns: - bytes: Data returned from the remote system.""" + Tuple (result code, result code string, server response): + The non-zero result code means error. + The server response may contain additional information about + password policy violations or other errors. + """ diff --git a/src/krb5/_change_password.pyx b/src/krb5/_change_password.pyx index 465f8a1..40ae6d0 100644 --- a/src/krb5/_change_password.pyx +++ b/src/krb5/_change_password.pyx @@ -38,16 +38,21 @@ def set_password( Creds creds not None, const unsigned char[:] newpw not None, change_password_for: typing.Optional[Principal] = None, -) -> bytes: +) -> typing.Tuple[int, bytes, bytes]: cdef krb5_error_code err = 0 cdef int result_code cdef krb5_data result_code_string cdef krb5_data result_string - cdef char *newpw_ptr = &newpw[0] + cdef char *newpw_ptr cdef krb5_principal change_password_for_ptr = NULL cdef size_t length cdef char *value + if len(newpw) > 0: + newpw_ptr = &newpw[0] + else: + newpw_ptr = b"" + pykrb5_init_krb5_data(&result_code_string) pykrb5_init_krb5_data(&result_string) @@ -71,11 +76,19 @@ def set_password( pykrb5_get_krb5_data(&result_code_string, &length, &value) if length == 0: - data_bytes = b"" + result_code_bytes = b"" else: - data_bytes = value[:length] + result_code_bytes = value[:length] + + pykrb5_get_krb5_data(&result_string, &length, &value) + + if length == 0: + result_string_bytes = b"" + else: + result_string_bytes = value[:length] + - return data_bytes + return (result_code, result_code_bytes, result_string_bytes) finally: pykrb5_free_data_contents(context.raw, &result_code_string) @@ -86,16 +99,21 @@ def set_password_using_ccache( CCache ccache not None, const unsigned char[:] newpw not None, change_password_for: typing.Optional[Principal] = None, -) -> bytes: +) -> typing.Tuple[int, bytes, bytes]: cdef krb5_error_code err = 0 cdef int result_code cdef krb5_data result_code_string cdef krb5_data result_string - cdef char *newpw_ptr = &newpw[0] + cdef char *newpw_ptr cdef krb5_principal change_password_for_ptr = NULL cdef size_t length cdef char *value + if len(newpw) > 0: + newpw_ptr = &newpw[0] + else: + newpw_ptr = b"" + pykrb5_init_krb5_data(&result_code_string) pykrb5_init_krb5_data(&result_string) @@ -116,14 +134,22 @@ def set_password_using_ccache( if err: raise Krb5Error(context, err) + pykrb5_get_krb5_data(&result_code_string, &length, &value) + + if length == 0: + result_code_bytes = b"" + else: + result_code_bytes = value[:length] + pykrb5_get_krb5_data(&result_string, &length, &value) if length == 0: - data_bytes = b"" + result_string_bytes = b"" else: - data_bytes = value[:length] + result_string_bytes = value[:length] + - return data_bytes + return (result_code, result_code_bytes, result_string_bytes) finally: pykrb5_free_data_contents(context.raw, &result_code_string) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index 1fe2ce7..6089df8 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -10,25 +10,99 @@ def test_set_password(realm: k5test.K5Realm) -> None: realm.start_kadmind() - princ_name = "userexp@" + realm.realm + princ_name = "exp@" + realm.realm + old_password = realm.password("userexp") + weak_password = "sh0rt" + empty_password = "" + new_password = realm.password("user") - realm.run_kadminl(["addprinc", "-pw", realm.password("userexp"), "+needchange", princ_name]) + realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) + realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, princ_name.encode()) opt = krb5.get_init_creds_opt_alloc(ctx) with pytest.raises(krb5.Krb5Error) as exc: - krb5.get_init_creds_password(ctx, princ, opt, password=realm.password("userexp").encode()) - assert exc.value.err_code == -1765328361 #KRB5KDC_ERR_KEY_EXP + krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) + assert exc.value.err_code == -1765328361 # KRB5KDC_ERR_KEY_EXP - creds = krb5.get_init_creds_password(ctx, princ, opt, realm.password("userexp").encode(), in_tkt_service=b"kadmin/changepw") + creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) - newpw = realm.password("user").encode() - result = krb5.set_password(ctx, creds, newpw, princ) + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, empty_password.encode(), princ) + assert result_code != 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 - creds = krb5.get_init_creds_password(ctx, princ, opt, newpw) + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, weak_password.encode(), princ) + assert result_code != 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 + + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, new_password.encode(), princ) + assert result_code == 0 + + creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) + realm.run_kadminl(["delprinc", "-force", princ_name]) + realm.run_kadminl(["delpol", "-force", "pwpol"]) + + realm.stop_kadmind() + + +@pytest.mark.requires_api("set_password_using_ccache") +def test_set_password_using_ccache(realm: k5test.K5Realm) -> None: + realm.start_kadmind() + + princ_name = "exp@" + realm.realm + old_password = realm.password("userexp") + weak_password = "sh0rt" + empty_password = "" + new_password = realm.password("user") + + realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) + realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + + ctx = krb5.init_context() + princ = krb5.parse_name_flags(ctx, princ_name.encode()) + opt = krb5.get_init_creds_opt_alloc(ctx) + + with pytest.raises(krb5.Krb5Error) as exc: + krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) + assert exc.value.err_code == -1765328361 # KRB5KDC_ERR_KEY_EXP + + creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") + assert isinstance(creds, krb5.Creds) + + cc = krb5.cc_new_unique(ctx, b"MEMORY") + krb5.cc_initialize(ctx, cc, princ) + krb5.cc_store_cred(ctx, cc, creds) + + (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( + ctx, cc, empty_password.encode(), princ + ) + assert result_code != 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 + + (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( + ctx, cc, weak_password.encode(), princ + ) + assert result_code != 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 + + (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( + ctx, cc, new_password.encode(), princ + ) + assert result_code == 0 + + creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) + assert isinstance(creds, krb5.Creds) + + realm.run_kadminl(["delprinc", "-force", princ_name]) + realm.run_kadminl(["delpol", "-force", "pwpol"]) + realm.stop_kadmind() From 54ac27254459a36577fc43841fa4103d7b19e0c8 Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 08:12:46 +0000 Subject: [PATCH 07/18] add change_password --- src/krb5/_change_password.pyi | 78 ++++++++++++++++++++++++++++------- src/krb5/_change_password.pyx | 18 ++++++++ 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/krb5/_change_password.pyi b/src/krb5/_change_password.pyi index 9e76528..8043f0d 100644 --- a/src/krb5/_change_password.pyi +++ b/src/krb5/_change_password.pyi @@ -5,6 +5,42 @@ from krb5._context import Context from krb5._creds import Creds from krb5._principal import Principal +def change_password( + context: Context, + creds: Creds, + newpw: bytes, +) -> typing.Tuple[int, bytes, bytes]: + """Set a password for the specified credentials owner. + + + This function implements the set password operation of ``RFC 3244``, + for interoperability with Microsoft Windows implementations. + It uses the credentials `creds` to change the password to `newpw`. + + Note: obtain the `creds` using `get_init_creds_password()` with + in_tkt_service set to "kadmin/changepw". + + Args: + context: Krb5 context. + creds: Credentials for kadmin/changepw service. + newpw: New password. + change_password_for: `None` or the principal to set the password for. + + Returns: + Tuple (result_code, result_code_string, server_response): + The non-zero result code means error. + The server response may contain additional information about + password policy violations or other errors. + + The possible values of the output result_code are: + + `KRB5_KPASSWD_SUCCESS` (0) - success + `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error + `KRB5_KPASSWD_HARDERROR` (2) - Server error + `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error + `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected + """ + def set_password( context: Context, creds: Creds, @@ -16,18 +52,16 @@ def set_password( This function implements the set password operation of ``RFC 3244``, for interoperability with Microsoft Windows implementations. - It uses the credentials creds to set the password newpw for the - principal change_password_for. - If change_password_for is `None`, the password is set for the principal - owning creds. If change_password_for is not `None`, the change is + It uses the credentials `creds` to set the password `newpw` for the + principal `change_password_for`. + If `change_password_for` is `None`, the password is set for the principal + owning creds. If `change_password_for` is not `None`, the change is performed on the specified principal. Note: to change the expired password for owner, obtain the owner creds using - get_init_creds_password() with in_tkt_service set to "kadmin/changepw" and + `get_init_creds_password()` with in_tkt_service set to "kadmin/changepw" and then use those creds to set the new password. - This is only present when compiled against MIT 1.7 or newer. - Args: context: Krb5 context. creds: Credentials for kadmin/changepw service. @@ -35,10 +69,18 @@ def set_password( change_password_for: `None` or the principal to set the password for. Returns: - Tuple (result code, result code string, server response): + Tuple (result_code, result_code_string, server_response): The non-zero result code means error. The server response may contain additional information about password policy violations or other errors. + + The possible values of the output result_code are: + + `KRB5_KPASSWD_SUCCESS` (0) - success + `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error + `KRB5_KPASSWD_HARDERROR` (2) - Server error + `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error + `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected """ def set_password_using_ccache( @@ -52,14 +94,12 @@ def set_password_using_ccache( This function implements the set password operation of ``RFC 3244``, for interoperability with Microsoft Windows implementations. - It uses the cached credentials from ccache to set the password newpw for - the principal change_password_for. - If change_password_for is `None`, the change is performed on the default - principal in ccache. If change_password_for is not `None`, the change is + It uses the cached credentials from `ccache` to set the password `newpw` for + the principal `change_password_for`. + If `change_password_for` is `None`, the change is performed on the default + principal in ccache. If `change_password_for` is not `None`, the change is performed on the specified principal. - This is only present when compiled against MIT 1.7 or newer. - Args: context: Krb5 context. ccache: Credential cache. @@ -67,8 +107,16 @@ def set_password_using_ccache( change_password_for: `None` or the principal to set the password for. Returns: - Tuple (result code, result code string, server response): + Tuple (result_code, result_code_string, server_response): The non-zero result code means error. The server response may contain additional information about password policy violations or other errors. + + The possible values of the output result_code are: + + `KRB5_KPASSWD_SUCCESS` (0) - success + `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error + `KRB5_KPASSWD_HARDERROR` (2) - Server error + `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error + `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected """ diff --git a/src/krb5/_change_password.pyx b/src/krb5/_change_password.pyx index 40ae6d0..7e2f557 100644 --- a/src/krb5/_change_password.pyx +++ b/src/krb5/_change_password.pyx @@ -33,6 +33,15 @@ cdef extern from "python_krb5.h": krb5_data *result_string ) nogil + krb5_error_code krb5_change_password( + krb5_context context, + krb5_creds *creds, + const char *newpw, + int *result_code, + krb5_data *result_code_string, + krb5_data *result_string + ) nogil + def set_password( Context context not None, Creds creds not None, @@ -154,3 +163,12 @@ def set_password_using_ccache( finally: pykrb5_free_data_contents(context.raw, &result_code_string) pykrb5_free_data_contents(context.raw, &result_string) + +def change_password( + Context context not None, + Creds creds not None, + const unsigned char[:] newpw not None, +) -> typing.Tuple[int, bytes, bytes]: + + return set_password(context, creds, newpw, None) + From c0fe4aaf5b47d1f0fdd678f7a15c3ac4661dedaf Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 08:13:01 +0000 Subject: [PATCH 08/18] heimdal test pre-work --- tests/test_changepw.py | 35 ++++++++++------------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index 6089df8..7b012cf 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -1,4 +1,4 @@ -import typing +import os import k5test import pytest @@ -8,6 +8,8 @@ @pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: + if realm.provider == "heimdal" and os.path.isfile("/etc/redhat-release"): + realm.kadmind = "/usr/libexec/heimdal-kadmind" realm.start_kadmind() princ_name = "exp@" + realm.realm @@ -16,8 +18,12 @@ def test_set_password(realm: k5test.K5Realm) -> None: empty_password = "" new_password = realm.password("user") - realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) - realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + if realm.provider == "mit": + realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) + realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + else: + realm.run_kadmin(["-l", "add", "-p", old_password, princ_name]) + realm.run_kadmin(["-l", "modify", "-a", "requires-pw-change", princ_name]) ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, princ_name.encode()) @@ -46,28 +52,7 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) - realm.run_kadminl(["delprinc", "-force", princ_name]) - realm.run_kadminl(["delpol", "-force", "pwpol"]) - - realm.stop_kadmind() - - -@pytest.mark.requires_api("set_password_using_ccache") -def test_set_password_using_ccache(realm: k5test.K5Realm) -> None: - realm.start_kadmind() - - princ_name = "exp@" + realm.realm - old_password = realm.password("userexp") - weak_password = "sh0rt" - empty_password = "" - new_password = realm.password("user") - - realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) - realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) - - ctx = krb5.init_context() - princ = krb5.parse_name_flags(ctx, princ_name.encode()) - opt = krb5.get_init_creds_opt_alloc(ctx) + realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) From be304763eceb0a0d1c2d14b6ea000a729ea7dcbc Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 11:42:40 +0000 Subject: [PATCH 09/18] add change_password to __init__ --- src/krb5/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/krb5/__init__.py b/src/krb5/__init__.py index 5993c4b..482caa7 100644 --- a/src/krb5/__init__.py +++ b/src/krb5/__init__.py @@ -189,12 +189,17 @@ try: - from krb5._change_password import set_password, set_password_using_ccache + from krb5._change_password import ( + change_password, + set_password, + set_password_using_ccache, + ) except ImportError: pass else: __all__.append("set_password") __all__.append("set_password_using_ccache") + __all__.append("change_password") try: From 6283102b6737fd30894a38482436ef6fb9127ce6 Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 11:43:06 +0000 Subject: [PATCH 10/18] bind change_password directly to library --- src/krb5/_change_password.pyx | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/krb5/_change_password.pyx b/src/krb5/_change_password.pyx index 7e2f557..20fffc2 100644 --- a/src/krb5/_change_password.pyx +++ b/src/krb5/_change_password.pyx @@ -96,7 +96,6 @@ def set_password( else: result_string_bytes = value[:length] - return (result_code, result_code_bytes, result_string_bytes) finally: @@ -157,7 +156,6 @@ def set_password_using_ccache( else: result_string_bytes = value[:length] - return (result_code, result_code_bytes, result_string_bytes) finally: @@ -169,6 +167,51 @@ def change_password( Creds creds not None, const unsigned char[:] newpw not None, ) -> typing.Tuple[int, bytes, bytes]: + cdef krb5_error_code err = 0 + cdef int result_code + cdef krb5_data result_code_string + cdef krb5_data result_string + cdef char *newpw_ptr + cdef size_t length + cdef char *value + + if len(newpw) > 0: + newpw_ptr = &newpw[0] + else: + newpw_ptr = b"" - return set_password(context, creds, newpw, None) + pykrb5_init_krb5_data(&result_code_string) + pykrb5_init_krb5_data(&result_string) + try: + err = krb5_change_password( + context.raw, + creds.get_pointer(), + newpw_ptr, + &result_code, + &result_code_string, + &result_string + ) + + if err: + raise Krb5Error(context, err) + + pykrb5_get_krb5_data(&result_code_string, &length, &value) + + if length == 0: + result_code_bytes = b"" + else: + result_code_bytes = value[:length] + + pykrb5_get_krb5_data(&result_string, &length, &value) + + if length == 0: + result_string_bytes = b"" + else: + result_string_bytes = value[:length] + + return (result_code, result_code_bytes, result_string_bytes) + + finally: + pykrb5_free_data_contents(context.raw, &result_code_string) + pykrb5_free_data_contents(context.raw, &result_string) From c40bc5b88c74a4cbea2b7cedbae051c6042b2ea0 Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 11:43:52 +0000 Subject: [PATCH 11/18] heimdal test attempt (unsuccessful) --- tests/test_changepw.py | 74 +++++++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index 7b012cf..671153e 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -8,9 +8,37 @@ @pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: - if realm.provider == "heimdal" and os.path.isfile("/etc/redhat-release"): + if realm.provider == "mit": + realm.start_kadmind() + elif realm.provider == "heimdal" and os.path.isfile("/etc/redhat-release"): + # This is a RHEL start/stop demonstration for Heimdal realm.kadmind = "/usr/libexec/heimdal-kadmind" - realm.start_kadmind() + kadmind_args = [ + realm.kadmind, + "--config-file=%s" % (realm.env["KRB5_CONFIG"]), + # "--keytab=%s" % (realm.keytab), + "--ports=%s" % (realm.portbase + 1), + ] + + realm._kadmind_proc = realm._start_daemon(kadmind_args) + + changepw_keytab = os.path.join(realm.tmpdir, "changepw.keytab") + realm.run_kadminl( + [ + "ext_keytab", + "-k", + changepw_keytab, + "kadmin/changepw", + ] + ) + + kpasswdd_args = [ + "/usr/libexec/kpasswdd", + "--config-file=%s" % (realm.env["KRB5_CONFIG"]), + "--keytab=%s" % (changepw_keytab), + "--port=%s" % (realm.portbase + 2), + ] + kpasswdd_proc = realm._start_daemon(kpasswdd_args, realm.env) princ_name = "exp@" + realm.realm old_password = realm.password("userexp") @@ -22,8 +50,21 @@ def test_set_password(realm: k5test.K5Realm) -> None: realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) else: - realm.run_kadmin(["-l", "add", "-p", old_password, princ_name]) - realm.run_kadmin(["-l", "modify", "-a", "requires-pw-change", princ_name]) + # This demonstrates how to create user with expired password on Heimdal + realm.run_kadminl( + [ + "add", + "-p", + old_password, + "--max-ticket-life=1 day", + "--max-renewable-life=1 week", + "--expiration-time=never", + "--pw-expiration-time=never", + "--policy=default", + "--attributes=requires-pw-change", + princ_name, + ] + ) ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, princ_name.encode()) @@ -36,23 +77,29 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) - (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, empty_password.encode(), princ) + (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, new_password.encode()) + assert result_code == 0 + assert result_code != 0 - assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + if realm.provider == "mit": + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 + + (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, weak_password.encode()) - (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, weak_password.encode(), princ) assert result_code != 0 - assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + if realm.provider == "mit": + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 - (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, new_password.encode(), princ) + (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, new_password.encode()) assert result_code == 0 creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) - realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + if realm.provider == "mit": + realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) @@ -90,4 +137,7 @@ def test_set_password(realm: k5test.K5Realm) -> None: realm.run_kadminl(["delprinc", "-force", princ_name]) realm.run_kadminl(["delpol", "-force", "pwpol"]) + if kpasswdd_proc: + realm._stop_daemon(kpasswdd_proc) + realm.stop_kadmind() From deb5354654d4659d292ee8e5f0244449c86cdad3 Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 11:46:40 +0000 Subject: [PATCH 12/18] removed heimdal testing code --- tests/test_changepw.py | 74 ++++++++---------------------------------- 1 file changed, 13 insertions(+), 61 deletions(-) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index 671153e..b3146fb 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -8,37 +8,12 @@ @pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: - if realm.provider == "mit": - realm.start_kadmind() - elif realm.provider == "heimdal" and os.path.isfile("/etc/redhat-release"): - # This is a RHEL start/stop demonstration for Heimdal - realm.kadmind = "/usr/libexec/heimdal-kadmind" - kadmind_args = [ - realm.kadmind, - "--config-file=%s" % (realm.env["KRB5_CONFIG"]), - # "--keytab=%s" % (realm.keytab), - "--ports=%s" % (realm.portbase + 1), - ] - - realm._kadmind_proc = realm._start_daemon(kadmind_args) - - changepw_keytab = os.path.join(realm.tmpdir, "changepw.keytab") - realm.run_kadminl( - [ - "ext_keytab", - "-k", - changepw_keytab, - "kadmin/changepw", - ] - ) - - kpasswdd_args = [ - "/usr/libexec/kpasswdd", - "--config-file=%s" % (realm.env["KRB5_CONFIG"]), - "--keytab=%s" % (changepw_keytab), - "--port=%s" % (realm.portbase + 2), - ] - kpasswdd_proc = realm._start_daemon(kpasswdd_args, realm.env) + + if realm.provider != "mit": + # Heimdal testing requires complicated kadmind and kpasswdd setup + return + + realm.start_kadmind() princ_name = "exp@" + realm.realm old_password = realm.password("userexp") @@ -46,25 +21,8 @@ def test_set_password(realm: k5test.K5Realm) -> None: empty_password = "" new_password = realm.password("user") - if realm.provider == "mit": - realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) - realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) - else: - # This demonstrates how to create user with expired password on Heimdal - realm.run_kadminl( - [ - "add", - "-p", - old_password, - "--max-ticket-life=1 day", - "--max-renewable-life=1 week", - "--expiration-time=never", - "--pw-expiration-time=never", - "--policy=default", - "--attributes=requires-pw-change", - princ_name, - ] - ) + realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) + realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, princ_name.encode()) @@ -81,16 +39,14 @@ def test_set_password(realm: k5test.K5Realm) -> None: assert result_code == 0 assert result_code != 0 - if realm.provider == "mit": - assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, weak_password.encode()) assert result_code != 0 - if realm.provider == "mit": - assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + assert result_code_string.find(b"rejected") > 0 + assert result_string.find(b"too short") > 0 (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, new_password.encode()) assert result_code == 0 @@ -98,8 +54,7 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) - if realm.provider == "mit": - realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) @@ -137,7 +92,4 @@ def test_set_password(realm: k5test.K5Realm) -> None: realm.run_kadminl(["delprinc", "-force", princ_name]) realm.run_kadminl(["delpol", "-force", "pwpol"]) - if kpasswdd_proc: - realm._stop_daemon(kpasswdd_proc) - realm.stop_kadmind() From c999441e660f59c6984c9d28eb18d6ce93491b2b Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 12:00:16 +0000 Subject: [PATCH 13/18] fixed MIT tests --- tests/test_changepw.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index b3146fb..e5a8679 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -35,8 +35,7 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) - (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, new_password.encode()) - assert result_code == 0 + (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, empty_password.encode()) assert result_code != 0 assert result_code_string.find(b"rejected") > 0 @@ -54,7 +53,8 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) - realm.run_kadminl(["modprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) + realm.run_kadminl(["cpw", "-pw", old_password, princ_name]) + realm.run_kadminl(["modprinc", "+needchange", princ_name]) with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) From de9bbdb9ce48e8c010d6718b3540e096656faacf Mon Sep 17 00:00:00 2001 From: zarganum Date: Fri, 23 Aug 2024 12:03:22 +0000 Subject: [PATCH 14/18] fixed isort --- src/krb5/_krb5_types.pxd | 1 + 1 file changed, 1 insertion(+) diff --git a/src/krb5/_krb5_types.pxd b/src/krb5/_krb5_types.pxd index 5e05c04..bd24f8d 100644 --- a/src/krb5/_krb5_types.pxd +++ b/src/krb5/_krb5_types.pxd @@ -3,6 +3,7 @@ from libc.stdint cimport int32_t, uint8_t, uint32_t + cdef extern from "python_krb5.h": """ // The Heimdal and MIT krb5 libraries have different implementations of the krb5_data struct. From 8817f6002ca29621a0828dfa327b5306aa8d97f8 Mon Sep 17 00:00:00 2001 From: zarganum Date: Mon, 26 Aug 2024 08:32:08 +0000 Subject: [PATCH 15/18] drop change_password(), return named tuple, decode result string, extend test with admin --- setup.py | 2 +- src/krb5/__init__.py | 22 ++--- ..._change_password.pyi => _set_password.pyi} | 83 +++++++------------ ..._change_password.pyx => _set_password.pyx} | 77 +++-------------- tests/test_changepw.py | 62 +++++++++++--- 5 files changed, 101 insertions(+), 145 deletions(-) rename src/krb5/{_change_password.pyi => _set_password.pyi} (50%) rename src/krb5/{_change_password.pyx => _set_password.pyx} (68%) diff --git a/setup.py b/setup.py index 460e56b..e6fabdf 100755 --- a/setup.py +++ b/setup.py @@ -220,7 +220,6 @@ def get_krb5_lib_path( "context", ("context_mit", "krb5_init_secure_context"), "creds", - ("change_password", "krb5_set_password"), ("creds_marshal_mit", "krb5_marshal_credentials"), ("creds_mit", "krb5_get_etype_info"), "creds_opt", @@ -237,6 +236,7 @@ def get_krb5_lib_path( ("kt_have_content", "krb5_kt_have_content"), "principal", ("principal_heimdal", "krb5_principal_get_realm"), + "set_password", "string", ("string_mit", "krb5_enctype_to_name"), ]: diff --git a/src/krb5/__init__.py b/src/krb5/__init__.py index 482caa7..9056edd 100644 --- a/src/krb5/__init__.py +++ b/src/krb5/__init__.py @@ -83,6 +83,11 @@ parse_name_flags, unparse_name_flags, ) +from krb5._set_password import ( + SetPasswordResult, + set_password, + set_password_using_ccache, +) from krb5._string import enctype_to_string, string_to_enctype __all__ = [ @@ -101,6 +106,7 @@ "Principal", "PrincipalParseFlags", "PrincipalUnparseFlags", + "SetPasswordResult", "TicketFlags", "TicketTimes", "build_principal", @@ -155,6 +161,8 @@ "kt_resolve", "parse_name_flags", "set_default_realm", + "set_password", + "set_password_using_ccache", "set_real_time", "string_to_enctype", "timeofday", @@ -188,20 +196,6 @@ __all__.append("unmarshal_credentials") -try: - from krb5._change_password import ( - change_password, - set_password, - set_password_using_ccache, - ) -except ImportError: - pass -else: - __all__.append("set_password") - __all__.append("set_password_using_ccache") - __all__.append("change_password") - - try: from krb5._ccache_match import cc_cache_match except ImportError: diff --git a/src/krb5/_change_password.pyi b/src/krb5/_set_password.pyi similarity index 50% rename from src/krb5/_change_password.pyi rename to src/krb5/_set_password.pyi index 8043f0d..5187dbd 100644 --- a/src/krb5/_change_password.pyi +++ b/src/krb5/_set_password.pyi @@ -5,48 +5,17 @@ from krb5._context import Context from krb5._creds import Creds from krb5._principal import Principal -def change_password( - context: Context, - creds: Creds, - newpw: bytes, -) -> typing.Tuple[int, bytes, bytes]: - """Set a password for the specified credentials owner. - - - This function implements the set password operation of ``RFC 3244``, - for interoperability with Microsoft Windows implementations. - It uses the credentials `creds` to change the password to `newpw`. - - Note: obtain the `creds` using `get_init_creds_password()` with - in_tkt_service set to "kadmin/changepw". - - Args: - context: Krb5 context. - creds: Credentials for kadmin/changepw service. - newpw: New password. - change_password_for: `None` or the principal to set the password for. - - Returns: - Tuple (result_code, result_code_string, server_response): - The non-zero result code means error. - The server response may contain additional information about - password policy violations or other errors. - - The possible values of the output result_code are: - - `KRB5_KPASSWD_SUCCESS` (0) - success - `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error - `KRB5_KPASSWD_HARDERROR` (2) - Server error - `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error - `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected - """ +class SetPasswordResult(typing.NamedTuple): + result_code: int + result_code_string: bytes + result_string: str def set_password( context: Context, creds: Creds, newpw: bytes, change_password_for: typing.Optional[Principal], -) -> typing.Tuple[int, bytes, bytes]: +) -> SetPasswordResult: """Set a password for a principal using specified credentials. @@ -56,11 +25,10 @@ def set_password( principal `change_password_for`. If `change_password_for` is `None`, the password is set for the principal owning creds. If `change_password_for` is not `None`, the change is - performed on the specified principal. + performed on the specified principal, assuming enough privileges. - Note: to change the expired password for owner, obtain the owner creds using - `get_init_creds_password()` with in_tkt_service set to "kadmin/changepw" and - then use those creds to set the new password. + Note: the `creds` can be obtained using `get_init_creds_password()` with + `in_tkt_service` set to ``kadmin/changepw``. Args: context: Krb5 context. @@ -69,14 +37,17 @@ def set_password( change_password_for: `None` or the principal to set the password for. Returns: - Tuple (result_code, result_code_string, server_response): - The non-zero result code means error. - The server response may contain additional information about - password policy violations or other errors. + A named tuple containing the `result_code`, `result_code_string`, and an + optional `result_string`. + The non-zero `result_code` means error with a corresponding readable + representation in `result_code_string`. It is a `bytes` object. + The `result_string` is a server response that may contain useful + information about password policy violations or other errors. It is + decoded as a `string` according to ``RFC 3244``. - The possible values of the output result_code are: + The possible values of the output `result_code` are: - `KRB5_KPASSWD_SUCCESS` (0) - success + `KRB5_KPASSWD_SUCCESS` (0) - Success `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error `KRB5_KPASSWD_HARDERROR` (2) - Server error `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error @@ -88,7 +59,7 @@ def set_password_using_ccache( ccache: CCache, newpw: bytes, change_password_for: typing.Optional[Principal], -) -> typing.Tuple[int, bytes, bytes]: +) -> SetPasswordResult: """Set a password for a principal using cached credentials. @@ -100,6 +71,9 @@ def set_password_using_ccache( principal in ccache. If `change_password_for` is not `None`, the change is performed on the specified principal. + Note: the credentials can be obtained using `get_init_creds_password()` with + `in_tkt_service` set to ``kadmin/changepw`` and stored to `ccache`. + Args: context: Krb5 context. ccache: Credential cache. @@ -107,14 +81,17 @@ def set_password_using_ccache( change_password_for: `None` or the principal to set the password for. Returns: - Tuple (result_code, result_code_string, server_response): - The non-zero result code means error. - The server response may contain additional information about - password policy violations or other errors. + A named tuple containing the `result_code`, `result_code_string`, and an + optional `result_string`. + The non-zero `result_code` means error with a corresponding readable + representation in `result_code_string`. It is a `bytes` object. + The `result_string` is a server response that may contain useful + information about password policy violations or other errors. It is + decoded as a `string` according to ``RFC 3244``. - The possible values of the output result_code are: + The possible values of the output `result_code` are: - `KRB5_KPASSWD_SUCCESS` (0) - success + `KRB5_KPASSWD_SUCCESS` (0) - Success `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error `KRB5_KPASSWD_HARDERROR` (2) - Server error `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error diff --git a/src/krb5/_change_password.pyx b/src/krb5/_set_password.pyx similarity index 68% rename from src/krb5/_change_password.pyx rename to src/krb5/_set_password.pyx index 20fffc2..6f114d5 100644 --- a/src/krb5/_change_password.pyx +++ b/src/krb5/_set_password.pyx @@ -33,21 +33,21 @@ cdef extern from "python_krb5.h": krb5_data *result_string ) nogil - krb5_error_code krb5_change_password( - krb5_context context, - krb5_creds *creds, - const char *newpw, - int *result_code, - krb5_data *result_code_string, - krb5_data *result_string - ) nogil +SetPasswordResult = collections.namedtuple( + 'SetPasswordResult', + [ + 'result_code', + 'result_code_string', + 'result_string', + ], +) def set_password( Context context not None, Creds creds not None, const unsigned char[:] newpw not None, change_password_for: typing.Optional[Principal] = None, -) -> typing.Tuple[int, bytes, bytes]: +) -> SetPasswordResult: cdef krb5_error_code err = 0 cdef int result_code cdef krb5_data result_code_string @@ -96,7 +96,7 @@ def set_password( else: result_string_bytes = value[:length] - return (result_code, result_code_bytes, result_string_bytes) + return SetPasswordResult(result_code, result_code_bytes, result_string_bytes.decode("utf-8")) finally: pykrb5_free_data_contents(context.raw, &result_code_string) @@ -107,7 +107,7 @@ def set_password_using_ccache( CCache ccache not None, const unsigned char[:] newpw not None, change_password_for: typing.Optional[Principal] = None, -) -> typing.Tuple[int, bytes, bytes]: +) -> SetPasswordResult: cdef krb5_error_code err = 0 cdef int result_code cdef krb5_data result_code_string @@ -156,62 +156,9 @@ def set_password_using_ccache( else: result_string_bytes = value[:length] - return (result_code, result_code_bytes, result_string_bytes) + return SetPasswordResult(result_code, result_code_bytes, result_string_bytes.decode("utf-8")) finally: pykrb5_free_data_contents(context.raw, &result_code_string) pykrb5_free_data_contents(context.raw, &result_string) -def change_password( - Context context not None, - Creds creds not None, - const unsigned char[:] newpw not None, -) -> typing.Tuple[int, bytes, bytes]: - cdef krb5_error_code err = 0 - cdef int result_code - cdef krb5_data result_code_string - cdef krb5_data result_string - cdef char *newpw_ptr - cdef size_t length - cdef char *value - - if len(newpw) > 0: - newpw_ptr = &newpw[0] - else: - newpw_ptr = b"" - - pykrb5_init_krb5_data(&result_code_string) - pykrb5_init_krb5_data(&result_string) - - try: - err = krb5_change_password( - context.raw, - creds.get_pointer(), - newpw_ptr, - &result_code, - &result_code_string, - &result_string - ) - - if err: - raise Krb5Error(context, err) - - pykrb5_get_krb5_data(&result_code_string, &length, &value) - - if length == 0: - result_code_bytes = b"" - else: - result_code_bytes = value[:length] - - pykrb5_get_krb5_data(&result_string, &length, &value) - - if length == 0: - result_string_bytes = b"" - else: - result_string_bytes = value[:length] - - return (result_code, result_code_bytes, result_string_bytes) - - finally: - pykrb5_free_data_contents(context.raw, &result_code_string) - pykrb5_free_data_contents(context.raw, &result_string) diff --git a/tests/test_changepw.py b/tests/test_changepw.py index e5a8679..eb9df89 100644 --- a/tests/test_changepw.py +++ b/tests/test_changepw.py @@ -1,12 +1,9 @@ -import os - import k5test import pytest import krb5 -@pytest.mark.requires_api("set_password") def test_set_password(realm: k5test.K5Realm) -> None: if realm.provider != "mit": @@ -20,14 +17,24 @@ def test_set_password(realm: k5test.K5Realm) -> None: weak_password = "sh0rt" empty_password = "" new_password = realm.password("user") + new_password2 = realm.password("user~") + # setup realm.run_kadminl(["addpol", "-minlength", "6", "-minclasses", "2", "pwpol"]) realm.run_kadminl(["addprinc", "-pw", old_password, "-policy", "pwpol", "+needchange", princ_name]) ctx = krb5.init_context() princ = krb5.parse_name_flags(ctx, princ_name.encode()) + admin_princ = krb5.parse_name_flags(ctx, realm.admin_princ.encode()) opt = krb5.get_init_creds_opt_alloc(ctx) + # admin creds; will be reused with ccache as well + admin_creds = krb5.get_init_creds_password( + ctx, admin_princ, opt, realm.password("admin").encode(), in_tkt_service=b"kadmin/changepw" + ) + assert isinstance(admin_creds, krb5.Creds) + + # set_password for creds owner (self) with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) assert exc.value.err_code == -1765328361 # KRB5KDC_ERR_KEY_EXP @@ -35,33 +42,45 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) - (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, empty_password.encode()) - + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, empty_password.encode()) assert result_code != 0 assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 - - (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, weak_password.encode()) + assert result_string.find("too short") > 0 + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, weak_password.encode()) assert result_code != 0 assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + assert result_string.find("too short") > 0 - (result_code, result_code_string, result_string) = krb5.change_password(ctx, creds, new_password.encode()) + (result_code, result_code_string, result_string) = krb5.set_password(ctx, creds, new_password.encode()) assert result_code == 0 creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) + assert creds.client.name == princ.name + + # set_password for other principal using admin creds + (result_code, result_code_string, result_string) = krb5.set_password( + ctx, admin_creds, new_password2.encode(), change_password_for=princ + ) + assert result_code == 0 + + creds = krb5.get_init_creds_password(ctx, princ, opt, new_password2.encode()) + assert isinstance(creds, krb5.Creds) + assert creds.client.name == princ.name + # reset password locally for next test realm.run_kadminl(["cpw", "-pw", old_password, princ_name]) realm.run_kadminl(["modprinc", "+needchange", princ_name]) + # set_password_using_ccache with pytest.raises(krb5.Krb5Error) as exc: krb5.get_init_creds_password(ctx, princ, opt, password=old_password.encode()) assert exc.value.err_code == -1765328361 # KRB5KDC_ERR_KEY_EXP creds = krb5.get_init_creds_password(ctx, princ, opt, old_password.encode(), in_tkt_service=b"kadmin/changepw") assert isinstance(creds, krb5.Creds) + assert creds.client.name == princ.name cc = krb5.cc_new_unique(ctx, b"MEMORY") krb5.cc_initialize(ctx, cc, princ) @@ -72,14 +91,14 @@ def test_set_password(realm: k5test.K5Realm) -> None: ) assert result_code != 0 assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + assert result_string.find("too short") > 0 (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( ctx, cc, weak_password.encode(), princ ) assert result_code != 0 assert result_code_string.find(b"rejected") > 0 - assert result_string.find(b"too short") > 0 + assert result_string.find("too short") > 0 (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( ctx, cc, new_password.encode(), princ @@ -88,8 +107,27 @@ def test_set_password(realm: k5test.K5Realm) -> None: creds = krb5.get_init_creds_password(ctx, princ, opt, new_password.encode()) assert isinstance(creds, krb5.Creds) + assert creds.client.name == princ.name + + krb5.cc_destroy(ctx, cc) + + admin_cc = krb5.cc_new_unique(ctx, b"MEMORY") + krb5.cc_initialize(ctx, admin_cc, admin_princ) + krb5.cc_store_cred(ctx, admin_cc, admin_creds) + + # set_password for other principal using admin ccache + (result_code, result_code_string, result_string) = krb5.set_password_using_ccache( + ctx, admin_cc, new_password2.encode(), change_password_for=princ + ) + assert result_code == 0 + + creds = krb5.get_init_creds_password(ctx, princ, opt, new_password2.encode()) + assert isinstance(creds, krb5.Creds) + assert creds.client.name == princ.name realm.run_kadminl(["delprinc", "-force", princ_name]) realm.run_kadminl(["delpol", "-force", "pwpol"]) + krb5.cc_destroy(ctx, admin_cc) + realm.stop_kadmind() From 0854678d556e9d5444725bac1ab7b0597c4dbac0 Mon Sep 17 00:00:00 2001 From: zarganum Date: Mon, 26 Aug 2024 08:50:08 +0000 Subject: [PATCH 16/18] fix signature --- src/krb5/_set_password.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/krb5/_set_password.pyi b/src/krb5/_set_password.pyi index 5187dbd..7e33558 100644 --- a/src/krb5/_set_password.pyi +++ b/src/krb5/_set_password.pyi @@ -14,7 +14,7 @@ def set_password( context: Context, creds: Creds, newpw: bytes, - change_password_for: typing.Optional[Principal], + change_password_for: typing.Optional[Principal] = None, ) -> SetPasswordResult: """Set a password for a principal using specified credentials. @@ -58,7 +58,7 @@ def set_password_using_ccache( context: Context, ccache: CCache, newpw: bytes, - change_password_for: typing.Optional[Principal], + change_password_for: typing.Optional[Principal] = None, ) -> SetPasswordResult: """Set a password for a principal using cached credentials. From f823923f0ed2bc80961ab09ec20375674e9bdf33 Mon Sep 17 00:00:00 2001 From: zarganum Date: Mon, 26 Aug 2024 09:46:24 +0000 Subject: [PATCH 17/18] CI version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index eedf8fb..d7ad7e5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = krb5 -version = 0.6.0 +version = 0.6.0.post50 url = https://github.com/jborean93/pykrb5 author = Jordan Borean author_email = jborean93@gmail.com From 8b2e1d4fa07b038df3aa8bb58d4b49bdc4e6dff3 Mon Sep 17 00:00:00 2001 From: zarganum Date: Mon, 26 Aug 2024 19:30:49 +0000 Subject: [PATCH 18/18] docstring --- src/krb5/_set_password.pyi | 53 +++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/krb5/_set_password.pyi b/src/krb5/_set_password.pyi index 7e33558..3329849 100644 --- a/src/krb5/_set_password.pyi +++ b/src/krb5/_set_password.pyi @@ -6,9 +6,28 @@ from krb5._creds import Creds from krb5._principal import Principal class SetPasswordResult(typing.NamedTuple): + """The result returned by :meth:`set_password()` and + :meth:`set_password_using_ccache()`. + + The `result_code` and `result_code_string` is the library response:\n + KRB5_KPASSWD_SUCCESS (0) - Success\n + KRB5_KPASSWD_MALFORMED (1) - Malformed request error\n + KRB5_KPASSWD_HARDERROR (2) - Server error\n + KRB5_KPASSWD_AUTHERROR (3) - Authentication error\n + KRB5_KPASSWD_SOFTERROR (4) - Password change rejected\n + Note the `result_code_string` is a byte string. + + The `result_string` is a server protocol response that may contain useful + information about password policy violations or other errors. + It is decoded as a `string` according to ``RFC 3244`` + """ + result_code: int + """The library result code of the password change operation.""" result_code_string: bytes + """The byte string representation of the result code.""" result_string: str + """Server response string""" def set_password( context: Context, @@ -37,21 +56,8 @@ def set_password( change_password_for: `None` or the principal to set the password for. Returns: - A named tuple containing the `result_code`, `result_code_string`, and an - optional `result_string`. - The non-zero `result_code` means error with a corresponding readable - representation in `result_code_string`. It is a `bytes` object. - The `result_string` is a server response that may contain useful - information about password policy violations or other errors. It is - decoded as a `string` according to ``RFC 3244``. - - The possible values of the output `result_code` are: - - `KRB5_KPASSWD_SUCCESS` (0) - Success - `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error - `KRB5_KPASSWD_HARDERROR` (2) - Server error - `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error - `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected + SetPasswordResult: See `SetPasswordResult` for more information about + the return result. """ def set_password_using_ccache( @@ -81,19 +87,6 @@ def set_password_using_ccache( change_password_for: `None` or the principal to set the password for. Returns: - A named tuple containing the `result_code`, `result_code_string`, and an - optional `result_string`. - The non-zero `result_code` means error with a corresponding readable - representation in `result_code_string`. It is a `bytes` object. - The `result_string` is a server response that may contain useful - information about password policy violations or other errors. It is - decoded as a `string` according to ``RFC 3244``. - - The possible values of the output `result_code` are: - - `KRB5_KPASSWD_SUCCESS` (0) - Success - `KRB5_KPASSWD_MALFORMED` (1) - Malformed request error - `KRB5_KPASSWD_HARDERROR` (2) - Server error - `KRB5_KPASSWD_AUTHERROR` (3) - Authentication error - `KRB5_KPASSWD_SOFTERROR` (4) - Password change rejected + SetPasswordResult: See `SetPasswordResult` for more information about + the return result. """