From 7c2ad53e0792ebc2691ea783fd5790c5973264d8 Mon Sep 17 00:00:00 2001 From: Naksen Date: Thu, 17 Apr 2025 15:48:47 +0300 Subject: [PATCH 1/6] add: gssapi first working version --- .gitignore | 85 +++++++++++++++++++++-- Dockerfile | 39 +++++++++++ aioldap3.py | 166 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 12 ++++ main.py | 22 ++++++ poetry.lock | 52 +++++++++++++- pyproject.toml | 2 +- 7 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 main.py diff --git a/.gitignore b/.gitignore index 4beb292..7e4d3bf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,13 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ downloads/ +.idea/ +.DS_Store +.vscode/ eggs/ .eggs/ lib/ @@ -20,9 +22,13 @@ lib64/ parts/ sdist/ var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -37,13 +43,16 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover .hypothesis/ +.pytest_cache/ # Translations *.mo @@ -51,6 +60,16 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ @@ -58,10 +77,62 @@ docs/_build/ # PyBuilder target/ -# pyenv python configuration file +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv .python-version -misc/ -.pytest_cache/ -htmlcov/ -.vscode/ \ No newline at end of file +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ldap +*.ldif + +# ruff +.ruff_cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..176b0ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# The builder image, used to build the virtual environment +FROM python:3.12.6-bookworm AS builder + +RUN pip install poetry + +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_VIRTUALENVS_OPTIONS_NO_PIP=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache \ + POETRY_VIRTUALENVS_PATH=/venvs \ + VIRTUAL_ENV=/venvs/.venv \ + PATH="/venvs/.venv/bin:$PATH" + +WORKDIR /venvs + +COPY pyproject.toml poetry.lock ./ + +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --with=dev --no-root + +# The runtime image, used to just run the code provided its virtual environment +FROM python:3.12.6-slim-bookworm AS runtime + +WORKDIR /app +ARG VERSION + +ENV VIRTUAL_ENV=/venvs/.venv \ + PATH="/venvs/.venv/bin:$PATH" \ + VERSION=${VERSION:-beta} + +RUN set -eux; apt-get update -y && apt-get install netcat-traditional --no-install-recommends -y +RUN apt-get install -y --no-install-recommends krb5-user +RUN apt-get install -y iputils-ping + +COPY main.py /app/main.py +COPY aioldap3.py /app/aioldap3.py +COPY pyproject.toml / + +COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} diff --git a/aioldap3.py b/aioldap3.py index 1a7f77e..0f6b32a 100644 --- a/aioldap3.py +++ b/aioldap3.py @@ -247,6 +247,7 @@ import asyncio import asyncio.sslproto +import hashlib import logging import ssl from contextlib import suppress @@ -255,6 +256,9 @@ from types import TracebackType from typing import Any, AsyncGenerator, Callable, Literal, cast +import gssapi +import gssapi.exceptions +from gssapi.raw import ChannelBindings from ldap3.operation.add import add_operation from ldap3.operation.bind import bind_operation, bind_response_to_dict_fast from ldap3.operation.delete import delete_operation @@ -630,6 +634,21 @@ async def start_tls(self, ctx: ssl.SSLContext) -> None: # Wait for handshake await self._tls_event.wait() + def get_channel_bindings(self) -> ChannelBindings | None: + """Get channel bindings.""" + try: + server_certificate = self.transport.get_extra_info("peercert") + except: + return None + + if not server_certificate: + return None + + digest = hashlib.sha256(server_certificate).digest() + application_data = b"tls-server-end-point:" + digest + + return ChannelBindings(application_data=application_data) + @property def is_bound(self) -> bool: """Check if resp is bound.""" @@ -671,11 +690,18 @@ def __init__( server: Server, user: str | None = None, password: str | None = None, + sasl_mechanism: str | None = None, + cred_store: dict[bytes | str, bytes | str] | None = None, loop: asyncio.AbstractEventLoop | None = None, ) -> None: """Set server, user and pw.""" self._responses: dict[str, LDAPResponse] = {} self._msg_id = 0 + + self._sasl_in_progress = False + self._sasl_mechanism = sasl_mechanism + self._cred_store = cred_store + self.loop = loop or asyncio.get_running_loop() self.server = server @@ -725,6 +751,137 @@ def _next_msg_id(self) -> int: self._msg_id += 1 return self._msg_id + async def sasl_bind(self, hostname: str) -> LDAPResponse: + """Perform SASL bind.""" + if not self._sasl_in_progress: + self._sasl_in_progress = True + try: + if self._sasl_mechanism == "GSSAPI": + result = await self.sasl_gssapi(hostname) + else: + raise LDAPBindError("Unsupported SASL mechanism") + finally: + self._sasl_in_progress = False + + return result + + async def sasl_gssapi( + self, + hostname: str, + ) -> LDAPResponse: + """Perform SASL GSSAPI bind using the Kerberos v5 mechanism.""" + target_name = gssapi.Name( + "ldap@" + hostname, gssapi.NameType.hostbased_service + ) + + creds = gssapi.Credentials( + name=gssapi.Name(self.bind_dn), + usage="initiate", + store=self._cred_store, + ) + + channel_bindings = self._proto.get_channel_bindings() + + ctx = gssapi.SecurityContext( + name=target_name, + mech=gssapi.MechType.kerberos, + creds=creds, + channel_bindings=channel_bindings, + ) + + self._msg_id = 0 + + in_token = None + try: + while True: + print("Sending SASL token") + # print(f"Token: {in_token}") + out_token = ctx.step(in_token) + if out_token is None: + out_token = b"" + result = await self.send_sasl_negotiation(out_token) + in_token = result.data["saslCreds"] + try: + if ctx.complete: + break + except gssapi.exceptions.MissingContextError: + pass + + unwrapped_token = ctx.unwrap(in_token) + client_security_layers = self.proccess_end_token( + unwrapped_token.message + ) + out_token = ctx.wrap(bytes(client_security_layers), False) + return await self.send_sasl_negotiation(out_token.message) + except gssapi.exceptions.GSSError as exc: + print(f"GSSAPI error: {exc}") + await self.abort_sasl_negotiation() + raise LDAPBindError(f"LDAP GSSAPI error: {exc}") from exc + + async def abort_sasl_negotiation(self) -> None: + """Abort the SASL negotiation.""" + bind_req = bind_operation( + version=self.server.version, + authentication="SASL", + name=None, + password=None, + sasl_mechanism="", + sasl_credentials=None, + ) + + ldap_msg = LDAPClientProtocol.encapsulate_ldap_message( + self._next_msg_id, "bindRequest", bind_req + ) + + resp = self._proto.send(ldap_msg) + + await resp.wait() + + async def send_sasl_negotiation(self, payload: bytes) -> LDAPResponse: + """Send SASL negotiation data to the server.""" + bind_req = bind_operation( + version=self.server.version, + authentication="SASL", + name=None, + password=None, + sasl_mechanism="GSSAPI", + sasl_credentials=payload, + ) + + msg_id = self._next_msg_id + + # Generate ASN1 form of LDAP bind request + ldap_msg = LDAPClientProtocol.encapsulate_ldap_message( + msg_id, "bindRequest", bind_req + ) + + resp = self._proto.send(ldap_msg) + await resp.wait() + + # print(f"Response: {resp.data}") + + return resp + + def proccess_end_token(self, token: bytes) -> bytearray: + """Process the response we got at the end of our SASL negotiation.""" + if len(token) != 4: + raise LDAPBindError("Incorrect token length") + + server_security_layers = token[0] + if not isinstance(server_security_layers, int): + server_security_layers = ord(server_security_layers) # type: ignore + if server_security_layers in (0, 1) and token[1:] != "\x00\x00\x00": + raise LDAPBindError( + "Server max buffer size must be 0 if no security layer" + ) + if not (server_security_layers & 1): + raise LDAPBindError( + "Server requires a security layer, but this is not implemented" + ) + + client_security_layers = bytearray([1, 0, 0, 0]) + return client_security_layers + async def bind( self, bind_dn: str | None = None, @@ -786,6 +943,15 @@ async def bind( ntlm_client, ) + elif method == "SASL" and self._sasl_mechanism == "GSSAPI": + print(f"SASL GSSAPI bind to {self.server.host}\n") + resp = await self.sasl_bind(self.server.host) + + if resp.data["result"] != 0: + raise LDAPBindError("Invalid Credentials") + + self._proto.is_bound = True + return else: raise LDAPBindError("Unsupported Authentication Method") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c19896d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + aioldap: + build: + context: . + dockerfile: ./Dockerfile + image: aioldap + container_name: aioldap + hostname: krbclient + command: tail -f /dev/null + volumes: + - ./main.py:/app/main.py + - ./aioldap3.py:/app/aioldap3.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..eea2250 --- /dev/null +++ b/main.py @@ -0,0 +1,22 @@ +import asyncio + +import aioldap3 + + +async def gssapi_example() -> None: + conn = aioldap3.LDAPConnection( + server=aioldap3.Server(host="ruslan.md.multifactor.dev", port=389), + user="user@RUSLAN.MD.MULTIFACTOR.DEV", + sasl_mechanism="GSSAPI", + ) + + await conn.bind(method="SASL") + print(f"Whoami result = {await conn.whoami()}") + + +def main() -> None: + asyncio.run(gssapi_example()) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index 0522037..878fb22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "colorama" @@ -78,6 +78,17 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -92,6 +103,43 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "gssapi" +version = "1.9.0" +description = "Python GSSAPI Wrapper" +optional = false +python-versions = ">=3.8" +files = [ + {file = "gssapi-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:261e00ac426d840055ddb2199f4989db7e3ce70fa18b1538f53e392b4823e8f1"}, + {file = "gssapi-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14a1ae12fdf1e4c8889206195ba1843de09fe82587fa113112887cd5894587c6"}, + {file = "gssapi-1.9.0-cp310-cp310-win32.whl", hash = "sha256:2a9c745255e3a810c3e8072e267b7b302de0705f8e9a0f2c5abc92fe12b9475e"}, + {file = "gssapi-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:dfc1b4c0bfe9f539537601c9f187edc320daf488f694e50d02d0c1eb37416962"}, + {file = "gssapi-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67d9be5e34403e47fb5749d5a1ad4e5a85b568e6a9add1695edb4a5b879f7560"}, + {file = "gssapi-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11e9b92cef11da547fc8c210fa720528fd854038504103c1b15ae2a89dce5fcd"}, + {file = "gssapi-1.9.0-cp311-cp311-win32.whl", hash = "sha256:6c5f8a549abd187687440ec0b72e5b679d043d620442b3637d31aa2766b27cbe"}, + {file = "gssapi-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:59e1a1a9a6c5dc430dc6edfcf497f5ca00cf417015f781c9fac2e85652cd738f"}, + {file = "gssapi-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b66a98827fbd2864bf8993677a039d7ba4a127ca0d2d9ed73e0ef4f1baa7fd7f"}, + {file = "gssapi-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bddd1cc0c9859c5e0fd96d4d88eb67bd498fdbba45b14cdccfe10bfd329479f"}, + {file = "gssapi-1.9.0-cp312-cp312-win32.whl", hash = "sha256:10134db0cf01bd7d162acb445762dbcc58b5c772a613e17c46cf8ad956c4dfec"}, + {file = "gssapi-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:e28c7d45da68b7e36ed3fb3326744bfe39649f16e8eecd7b003b082206039c76"}, + {file = "gssapi-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cea344246935b5337e6f8a69bb6cc45619ab3a8d74a29fcb0a39fd1e5843c89c"}, + {file = "gssapi-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a5786bd9fcf435bd0c87dc95ae99ad68cefcc2bcc80c71fef4cb0ccdfb40f1e"}, + {file = "gssapi-1.9.0-cp313-cp313-win32.whl", hash = "sha256:c99959a9dd62358e370482f1691e936cb09adf9a69e3e10d4f6a097240e9fd28"}, + {file = "gssapi-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2e43f50450e81fe855888c53df70cdd385ada979db79463b38031710a12acd9"}, + {file = "gssapi-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c0e378d62b2fc352ca0046030cda5911d808a965200f612fdd1d74501b83e98f"}, + {file = "gssapi-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b74031c70864d04864b7406c818f41be0c1637906fb9654b06823bcc79f151dc"}, + {file = "gssapi-1.9.0-cp38-cp38-win32.whl", hash = "sha256:f2f3a46784d8127cc7ef10d3367dedcbe82899ea296710378ccc9b7cefe96f4c"}, + {file = "gssapi-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:a81f30cde21031e7b1f8194a3eea7285e39e551265e7744edafd06eadc1c95bc"}, + {file = "gssapi-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbc93fdadd5aab9bae594538b2128044b8c5cdd1424fe015a465d8a8a587411a"}, + {file = "gssapi-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b2a3c0a9beb895942d4b8e31f515e52c17026e55aeaa81ee0df9bbfdac76098"}, + {file = "gssapi-1.9.0-cp39-cp39-win32.whl", hash = "sha256:060b58b455d29ab8aca74770e667dca746264bee660ac5b6a7a17476edc2c0b8"}, + {file = "gssapi-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:11c9fe066edb0fa0785697eb0cecf2719c7ad1d9f2bf27be57b647a617bcfaa5"}, + {file = "gssapi-1.9.0.tar.gz", hash = "sha256:f468fac8f3f5fca8f4d1ca19e3cd4d2e10bd91074e7285464b22715d13548afe"}, +] + +[package.dependencies] +decorator = "*" + [[package]] name = "iniconfig" version = "2.0.0" @@ -322,4 +370,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "674dc342c6dfd05e5a529a5b75a4379ebded3ffb69f7a85c3090042e71ae5910" +content-hash = "6830da8c832853427041625449f4089307dd859cff6bc5cd3bd2d501ff274b55" diff --git a/pyproject.toml b/pyproject.toml index 853822c..da53035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.8.1" +gssapi = "^1.9.0" ldap3 = "^2.9.1" [tool.poetry.group.dev] @@ -35,7 +36,6 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.mypy] -plugins = ["sqlalchemy.ext.mypy.plugin", "pydantic.mypy"] ignore_missing_imports = true platform = "linux" disallow_untyped_defs = true From ddc7ef77de9b1e857f19419d88bf62469dc9bf6c Mon Sep 17 00:00:00 2001 From: Naksen Date: Wed, 23 Apr 2025 11:45:59 +0300 Subject: [PATCH 2/6] fix: ssl --- aioldap3.py | 63 +++++++++++------------------------------------------ 1 file changed, 13 insertions(+), 50 deletions(-) diff --git a/aioldap3.py b/aioldap3.py index 0f6b32a..e2a416d 100644 --- a/aioldap3.py +++ b/aioldap3.py @@ -247,7 +247,6 @@ import asyncio import asyncio.sslproto -import hashlib import logging import ssl from contextlib import suppress @@ -258,7 +257,6 @@ import gssapi import gssapi.exceptions -from gssapi.raw import ChannelBindings from ldap3.operation.add import add_operation from ldap3.operation.bind import bind_operation, bind_response_to_dict_fast from ldap3.operation.delete import delete_operation @@ -634,21 +632,6 @@ async def start_tls(self, ctx: ssl.SSLContext) -> None: # Wait for handshake await self._tls_event.wait() - def get_channel_bindings(self) -> ChannelBindings | None: - """Get channel bindings.""" - try: - server_certificate = self.transport.get_extra_info("peercert") - except: - return None - - if not server_certificate: - return None - - digest = hashlib.sha256(server_certificate).digest() - application_data = b"tls-server-end-point:" + digest - - return ChannelBindings(application_data=application_data) - @property def is_bound(self) -> bool: """Check if resp is bound.""" @@ -751,27 +734,27 @@ def _next_msg_id(self) -> int: self._msg_id += 1 return self._msg_id - async def sasl_bind(self, hostname: str) -> LDAPResponse: + async def sasl_bind(self) -> LDAPResponse: """Perform SASL bind.""" + logger.debug("start SASL BIND operation") if not self._sasl_in_progress: self._sasl_in_progress = True try: if self._sasl_mechanism == "GSSAPI": - result = await self.sasl_gssapi(hostname) + result = await self.sasl_gssapi() else: raise LDAPBindError("Unsupported SASL mechanism") finally: self._sasl_in_progress = False + logger.debug("done SASL BIND operation") + return result - async def sasl_gssapi( - self, - hostname: str, - ) -> LDAPResponse: + async def sasl_gssapi(self) -> LDAPResponse: """Perform SASL GSSAPI bind using the Kerberos v5 mechanism.""" target_name = gssapi.Name( - "ldap@" + hostname, gssapi.NameType.hostbased_service + "ldap@" + self.server.host, gssapi.NameType.hostbased_service ) creds = gssapi.Credentials( @@ -780,13 +763,10 @@ async def sasl_gssapi( store=self._cred_store, ) - channel_bindings = self._proto.get_channel_bindings() - ctx = gssapi.SecurityContext( name=target_name, mech=gssapi.MechType.kerberos, creds=creds, - channel_bindings=channel_bindings, ) self._msg_id = 0 @@ -794,8 +774,7 @@ async def sasl_gssapi( in_token = None try: while True: - print("Sending SASL token") - # print(f"Token: {in_token}") + logger.debug("Sending SASL token") out_token = ctx.step(in_token) if out_token is None: out_token = b"" @@ -814,7 +793,6 @@ async def sasl_gssapi( out_token = ctx.wrap(bytes(client_security_layers), False) return await self.send_sasl_negotiation(out_token.message) except gssapi.exceptions.GSSError as exc: - print(f"GSSAPI error: {exc}") await self.abort_sasl_negotiation() raise LDAPBindError(f"LDAP GSSAPI error: {exc}") from exc @@ -848,18 +826,14 @@ async def send_sasl_negotiation(self, payload: bytes) -> LDAPResponse: sasl_credentials=payload, ) - msg_id = self._next_msg_id - # Generate ASN1 form of LDAP bind request ldap_msg = LDAPClientProtocol.encapsulate_ldap_message( - msg_id, "bindRequest", bind_req + self._next_msg_id, "bindRequest", bind_req ) resp = self._proto.send(ldap_msg) await resp.wait() - # print(f"Response: {resp.data}") - return resp def proccess_end_token(self, token: bytes) -> bytearray: @@ -870,7 +844,8 @@ def proccess_end_token(self, token: bytes) -> bytearray: server_security_layers = token[0] if not isinstance(server_security_layers, int): server_security_layers = ord(server_security_layers) # type: ignore - if server_security_layers in (0, 1) and token[1:] != "\x00\x00\x00": + + if server_security_layers in (0, 1) and token[1:] != b"\x00\x00\x00": raise LDAPBindError( "Server max buffer size must be 0 if no security layer" ) @@ -945,7 +920,7 @@ async def bind( elif method == "SASL" and self._sasl_mechanism == "GSSAPI": print(f"SASL GSSAPI bind to {self.server.host}\n") - resp = await self.sasl_bind(self.server.host) + resp = await self.sasl_bind() if resp.data["result"] != 0: raise LDAPBindError("Invalid Credentials") @@ -1193,25 +1168,13 @@ async def unbind(self) -> None: async def start_tls(self, ctx: ssl.SSLContext | None = None) -> None: """Start tls protocol.""" - if hasattr(self, "_proto") or self._proto.transport.is_closing(): + if not hasattr(self, "_proto") or self._proto.transport.is_closing(): self._socket, self._proto = await self.loop.create_connection( lambda: LDAPClientProtocol(self.loop), self.server.host, self.server.port, ) - # Get SSL context from server obj, if - # it wasnt provided, it'll be the default one - - resp = await self.extended("1.3.6.1.4.1.1466.20037") - - if resp.data["description"] != "success": - raise LDAPStartTlsError( - "Server doesnt want us to use TLS. {}".format( - resp.data.get("message") - ) - ) - await self._proto.start_tls( ctx or cast(ssl.SSLContext, self.server.ssl_context) ) From 21618faab6afc7a2c75ad78e72fdece64cf55e5e Mon Sep 17 00:00:00 2001 From: Naksen Date: Thu, 24 Apr 2025 08:53:53 +0300 Subject: [PATCH 3/6] add: cred token import support --- aioldap3.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/aioldap3.py b/aioldap3.py index e2a416d..f1aa848 100644 --- a/aioldap3.py +++ b/aioldap3.py @@ -675,6 +675,7 @@ def __init__( password: str | None = None, sasl_mechanism: str | None = None, cred_store: dict[bytes | str, bytes | str] | None = None, + cred_token: bytes | None = None, loop: asyncio.AbstractEventLoop | None = None, ) -> None: """Set server, user and pw.""" @@ -684,6 +685,7 @@ def __init__( self._sasl_in_progress = False self._sasl_mechanism = sasl_mechanism self._cred_store = cred_store + self._cred_token = cred_token self.loop = loop or asyncio.get_running_loop() @@ -736,7 +738,7 @@ def _next_msg_id(self) -> int: async def sasl_bind(self) -> LDAPResponse: """Perform SASL bind.""" - logger.debug("start SASL BIND operation") + logger.debug(f"start SASL BIND operation to {self.server.host}") if not self._sasl_in_progress: self._sasl_in_progress = True try: @@ -747,7 +749,7 @@ async def sasl_bind(self) -> LDAPResponse: finally: self._sasl_in_progress = False - logger.debug("done SASL BIND operation") + logger.debug(f"done SASL BIND operation to {self.server.host}") return result @@ -757,11 +759,14 @@ async def sasl_gssapi(self) -> LDAPResponse: "ldap@" + self.server.host, gssapi.NameType.hostbased_service ) - creds = gssapi.Credentials( - name=gssapi.Name(self.bind_dn), - usage="initiate", - store=self._cred_store, - ) + if self._cred_token: + creds = gssapi.Credentials(token=self._cred_token) + else: + creds = gssapi.Credentials( + name=gssapi.Name(self.bind_dn), + usage="initiate", + store=self._cred_store, + ) ctx = gssapi.SecurityContext( name=target_name, @@ -919,7 +924,6 @@ async def bind( ) elif method == "SASL" and self._sasl_mechanism == "GSSAPI": - print(f"SASL GSSAPI bind to {self.server.host}\n") resp = await self.sasl_bind() if resp.data["result"] != 0: From c11a6726e7caf5b4438073625038c7e46600521a Mon Sep 17 00:00:00 2001 From: Naksen Date: Thu, 24 Apr 2025 08:59:37 +0300 Subject: [PATCH 4/6] refactor: remove dev stuf --- .gitignore | 9 ++++++++- Dockerfile | 39 --------------------------------------- docker-compose.yml | 12 ------------ main.py | 22 ---------------------- 4 files changed, 8 insertions(+), 74 deletions(-) delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml delete mode 100644 main.py diff --git a/.gitignore b/.gitignore index 7e4d3bf..c362780 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,11 @@ dmypy.json *.ldif # ruff -.ruff_cache \ No newline at end of file +.ruff_cache + +# certs +*.pem +*.crt + +# Dev stuff +dev \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 176b0ce..0000000 --- a/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# The builder image, used to build the virtual environment -FROM python:3.12.6-bookworm AS builder - -RUN pip install poetry - -ENV POETRY_NO_INTERACTION=1 \ - POETRY_VIRTUALENVS_IN_PROJECT=1 \ - POETRY_VIRTUALENVS_CREATE=1 \ - POETRY_VIRTUALENVS_OPTIONS_NO_PIP=1 \ - POETRY_CACHE_DIR=/tmp/poetry_cache \ - POETRY_VIRTUALENVS_PATH=/venvs \ - VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" - -WORKDIR /venvs - -COPY pyproject.toml poetry.lock ./ - -RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --with=dev --no-root - -# The runtime image, used to just run the code provided its virtual environment -FROM python:3.12.6-slim-bookworm AS runtime - -WORKDIR /app -ARG VERSION - -ENV VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" \ - VERSION=${VERSION:-beta} - -RUN set -eux; apt-get update -y && apt-get install netcat-traditional --no-install-recommends -y -RUN apt-get install -y --no-install-recommends krb5-user -RUN apt-get install -y iputils-ping - -COPY main.py /app/main.py -COPY aioldap3.py /app/aioldap3.py -COPY pyproject.toml / - -COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c19896d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - aioldap: - build: - context: . - dockerfile: ./Dockerfile - image: aioldap - container_name: aioldap - hostname: krbclient - command: tail -f /dev/null - volumes: - - ./main.py:/app/main.py - - ./aioldap3.py:/app/aioldap3.py diff --git a/main.py b/main.py deleted file mode 100644 index eea2250..0000000 --- a/main.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio - -import aioldap3 - - -async def gssapi_example() -> None: - conn = aioldap3.LDAPConnection( - server=aioldap3.Server(host="ruslan.md.multifactor.dev", port=389), - user="user@RUSLAN.MD.MULTIFACTOR.DEV", - sasl_mechanism="GSSAPI", - ) - - await conn.bind(method="SASL") - print(f"Whoami result = {await conn.whoami()}") - - -def main() -> None: - asyncio.run(gssapi_example()) - - -if __name__ == "__main__": - main() From f335e2c3192917441e53f6c7125d7b6baea98218 Mon Sep 17 00:00:00 2001 From: Naksen Date: Thu, 24 Apr 2025 09:00:24 +0300 Subject: [PATCH 5/6] refactor: add missing newline --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c362780..74efbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,4 @@ dmypy.json *.crt # Dev stuff -dev \ No newline at end of file +dev From f3b888518ac92b85bcc8b288be1b5c26b406fe91 Mon Sep 17 00:00:00 2001 From: Naksen Date: Thu, 24 Apr 2025 15:42:23 +0300 Subject: [PATCH 6/6] add: blocking function processing --- aioldap3.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/aioldap3.py b/aioldap3.py index f1aa848..6cc997d 100644 --- a/aioldap3.py +++ b/aioldap3.py @@ -753,6 +753,19 @@ async def sasl_bind(self) -> LDAPResponse: return result + def init_gssapi_credentials( + self, + name: gssapi.Name, + usage: str, + store: dict[bytes | str, bytes | str] | None, + ) -> gssapi.Credentials: + """Initialize GSSAPI credentials.""" + return gssapi.Credentials( + name=name, + usage=usage, + store=store, + ) + async def sasl_gssapi(self) -> LDAPResponse: """Perform SASL GSSAPI bind using the Kerberos v5 mechanism.""" target_name = gssapi.Name( @@ -762,10 +775,12 @@ async def sasl_gssapi(self) -> LDAPResponse: if self._cred_token: creds = gssapi.Credentials(token=self._cred_token) else: - creds = gssapi.Credentials( - name=gssapi.Name(self.bind_dn), - usage="initiate", - store=self._cred_store, + creds = await self.loop.run_in_executor( + None, + self.init_gssapi_credentials, + gssapi.Name(self.bind_dn), + "initiate", + self._cred_store, ) ctx = gssapi.SecurityContext( @@ -780,7 +795,11 @@ async def sasl_gssapi(self) -> LDAPResponse: try: while True: logger.debug("Sending SASL token") - out_token = ctx.step(in_token) + out_token = await self.loop.run_in_executor( + None, + ctx.step, + in_token, + ) if out_token is None: out_token = b"" result = await self.send_sasl_negotiation(out_token)