Skip to content

Commit

Permalink
set_password extended server response processing (#53)
Browse files Browse the repository at this point in the history
* set_password bytes result_string

* CI bump version

* CI revert version

* set_password response ADPolicyInfo and MIT chpw_message

* isort

* result structures, docs and code enums

* black format

* remove nesting from named tuples

* chpw_message locale encoding

* revert over-decoding and fixed enum

* removed ADPolicyInfo.to_bytes() etc

---------

Co-authored-by: zarganum <[email protected]>
  • Loading branch information
zarganum and zarganum authored Sep 17, 2024
1 parent bbaa991 commit a3fffd7
Show file tree
Hide file tree
Showing 8 changed files with 386 additions and 73 deletions.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ def get_krb5_lib_path(
("ccache_match", "krb5_cc_cache_match"),
("ccache_support_switch", "krb5_cc_support_switch"),
"cccol",
("chpw_message_mit", "krb5_chpw_message"),
"context",
("context_mit", "krb5_init_secure_context"),
"creds",
Expand Down
12 changes: 12 additions & 0 deletions src/krb5/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright: (c) 2021 Jordan Borean (@jborean93) <[email protected]>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)

from krb5._adpi import ADPolicyInfo, ADPolicyInfoProp
from krb5._ccache import (
CCache,
CredentialsRetrieveFlags,
Expand Down Expand Up @@ -85,12 +86,15 @@
)
from krb5._set_password import (
SetPasswordResult,
SetPasswordResultCode,
set_password,
set_password_using_ccache,
)
from krb5._string import enctype_to_string, string_to_enctype

__all__ = [
"ADPolicyInfo",
"ADPolicyInfoProp",
"CCache",
"Context",
"CredentialsRetrieveFlags",
Expand All @@ -107,6 +111,7 @@
"PrincipalParseFlags",
"PrincipalUnparseFlags",
"SetPasswordResult",
"SetPasswordResultCode",
"TicketFlags",
"TicketTimes",
"build_principal",
Expand Down Expand Up @@ -195,6 +200,13 @@
__all__.append("marshal_credentials")
__all__.append("unmarshal_credentials")

try:
from krb5._chpw_message_mit import chpw_message
except ImportError:
pass
else:
__all__.append("chpw_message")


try:
from krb5._ccache_match import cc_cache_match
Expand Down
71 changes: 71 additions & 0 deletions src/krb5/_adpi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import enum
import struct
import typing

FORMAT = "!HIIIQQ"


class ADPolicyInfoProp(enum.IntFlag):
COMPLEX = 0x00000001
NO_ANON_CHANGEv = 0x00000002
NO_CLEAR_CHANGE = 0x00000004
LOCKOUT_ADMINS = 0x00000008
STORE_CLEARTEXT = 0x00000010
REFUSE_CHANGE = 0x00000020


class ADPolicyInfo(typing.NamedTuple):
"""The structure containing the reasons for failed password change attempt.
Should be used to inform the end user how to meet the policy requirements.
This is specific to Active Directory and is returned as the
`server_response` by :meth:`set_password()` and
:meth:`set_password_using_ccache()`.
When using MIT library, this structure may be encoded back to bytes and
passed to :meth:`chpw_message()` to obtain a human readable response.
With Heimdal, it is required to provide a custom implementation based
on the known fields below.
The structure contains the following fields:\n
- `properties` - Password policy bit flags (see below)
- `min_length` - Minimal password length
- `history` - Number of passwords that this system remembers
- `max_age` - Maximum password age in 100 nanosecond units
- `min_age` - Minimum password age in 100 nanosecond units
The only known property flag is `COMPLEX` which means that the password must
meet certain character variety and not contain the user's name.
To convert `max_age` and `min_age` to seconds, divide them by 10,000,000.
"""

properties: ADPolicyInfoProp
min_length: int
history: int
max_age: int
min_age: int

@classmethod
def from_bytes(cls, data: bytes) -> "ADPolicyInfo":
"""Decode AD policy result from byte string
Args:
data: Serialized AD policy `server_response`
Returns:
ADPolicyInfo: Decoded AD policy result strcture
Raises:
ValueError: Invalid data length or wrong signature
"""
if len(data) != struct.calcsize(FORMAT):
raise ValueError("Invalid data length")
signature, min_length, history, flags, max_age, min_age = struct.unpack(FORMAT, data)
if signature != 0x0000:
raise ValueError("Invalid signature")
return cls(
min_length=min_length,
history=history,
max_age=max_age,
min_age=min_age,
properties=ADPolicyInfoProp(flags),
)
21 changes: 21 additions & 0 deletions src/krb5/_chpw_message_mit.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from krb5._context import Context

def chpw_message(context: Context, server_response: bytes) -> bytes:
"""This function processes the byte sequence returned as the
`server_response` by :meth:`set_password()` and
:meth:`set_password_using_ccache()` functions, and returns a human readable
byte string.
Note that `gettext` library is used to translate the strings according
to locale settings. For the list of existing translations, pls. refer
to MIT krb5 source code. Not all translations may be available on your
system. Caller is responsible for decoding the string according to
locale settings.
Args:
context: Krb5 context.
server_response: The `server_response` bytes received from the KDC.
Returns:
bytes: The human readable bytes string.
"""
49 changes: 49 additions & 0 deletions src/krb5/_chpw_message_mit.pyx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import typing

from krb5._exceptions import Krb5Error

from libc.string cimport strlen

from krb5._context cimport Context
from krb5._krb5_types cimport *


cdef extern from "python_krb5.h":
krb5_error_code krb5_chpw_message(
krb5_context context,
const krb5_data *server_string,
char **message_out
) nogil

void krb5_free_string(
krb5_context context,
char *string
) nogil

def chpw_message(
Context context not None,
const unsigned char[:] server_string not None,
) -> bytes:
cdef krb5_error_code err = 0
cdef krb5_data server_string_raw
cdef char *message_out = NULL

try:
if len(server_string) == 0:
pykrb5_set_krb5_data(&server_string_raw, 0, "")
else:
pykrb5_set_krb5_data(&server_string_raw, len(server_string), <char *>&server_string[0])

err = krb5_chpw_message(context.raw, &server_string_raw, &message_out)

if err:
raise Krb5Error(context, err)

if message_out is NULL:
return b""
else:
message_len = strlen(message_out)
return <bytes>message_out[:message_len]

finally:
krb5_free_string(context.raw, message_out)
52 changes: 38 additions & 14 deletions src/krb5/_set_password.pyi
Original file line number Diff line number Diff line change
@@ -1,31 +1,57 @@
import enum
import typing

from krb5._ccache import CCache
from krb5._context import Context
from krb5._creds import Creds
from krb5._principal import Principal

class SetPasswordResultCode(enum.IntEnum):
"""Password change result constants returned by :meth:`set_password()`
Follow RFC 3244 with additional Microsoft extensions.
"""

SUCCESS = ... # Success
MALFORMED = ... # Malformed request
HARDERROR = ... # Server error
AUTHERROR = ... # Authentication error
SOFTERROR = ... # Password change rejected
ACCESSDENIED = ... # Microsoft extension: Not authorized
BAD_VERSION = ... # Microsoft extension: Unknown RPC version
INITIAL_FLAG_NEEDED = ... # Microsoft extension:
# The presented credentials were not obtained using a password directly

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
The `result_code` and `result_code_string` are the pure library responses.
See `SetPasswordResultCode` for more information.
The `result_string` is a server protocol response that may contain useful
The `server_response` is a server protocol message that may contain useful
information about password policy violations or other errors.
Despite RFC 3244, the server response is not standardized and may vary.
Depending on `kpasswd` implementation, it may be returned as:\n
- 30-byte binary Active Directory Policy Information
- UTF-8 byte string (MIT KDC, potentially Heimdal KDC)
- raw bytes (unknown or custom implementation)
The trick is that Active Directory Policy Information always starts with
`0x0000` signature to distinguish from UTF-8.
So the client may try decoding the server response with either
:meth:`ADPolicyInfo.from_bytes()` or :meth:`bytes.decode()`.
And if the decoding fails with corresponding `ValueError` or
`UnicodeDecodeError`, the raw bytes should be analyzed.
See `ADPolicyInfo` for more information.
"""

result_code: int
result_code: SetPasswordResultCode
"""The library result code of the password change operation."""
result_code_string: bytes
"""The byte string representation of the result code."""
result_string: bytes
"""Server response string"""
server_response: bytes
"""Implementation-specific server response."""

def set_password(
context: Context,
Expand Down Expand Up @@ -54,8 +80,7 @@ def set_password(
change_password_for: `None` or the principal to set the password for.
Returns:
SetPasswordResult: See `SetPasswordResult` for more information about
the return result.
SetPasswordResult: See `SetPasswordResult` for more information.
"""

def set_password_using_ccache(
Expand Down Expand Up @@ -85,6 +110,5 @@ def set_password_using_ccache(
change_password_for: `None` or the principal to set the password for.
Returns:
SetPasswordResult: See `SetPasswordResult` for more information about
the return result.
SetPasswordResult: See `SetPasswordResult` for more information.
"""
Loading

0 comments on commit a3fffd7

Please sign in to comment.