Repository: XKNX/xknx

Issue:
Get correct password from keyfile with multiple interfaces
https://github.com/XKNX/xknx/blob/87851342fe46534316ee9ea79d638c7a39e9d13e/xknx/secure/keyring.py#L250

This assumes we have only one secure tunnelling interface in the keyring. If there are multiple we'd always get the first found element.

Here are some considerations:
- we should get the password by server-IA and user_id
- if no user_id is configured we should use a free tunnel based on a search request (MDT router will have to configure a user_id 🤷)
- if the IA is not known use SearchRequest results or do a DescriptionRequest (when host is configured) to get it
- raise if no found interface IA matches keyring IA

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/1074?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1074](https://codecov.io/gh/XKNX/xknx/pull/1074?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (c853af3) into [main](https://codecov.io/gh/XKNX/xknx/commit/87851342fe46534316ee9ea79d638c7a39e9d13e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (8785134) will **decrease** coverage by `0.04%`.
> The diff coverage is `84.61%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/1074/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://codecov.io/gh/XKNX/xknx/pull/1074?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main    #1074      +/-   ##
==========================================
- Coverage   96.36%   96.31%   -0.05%     
==========================================
  Files         138      138              
  Lines        8579     8583       +4     
==========================================
  Hits         8267     8267              
- Misses        312      316       +4     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1074?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/secure/keyring.py](https://codecov.io/gh/XKNX/xknx/pull/1074/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9zZWN1cmUva2V5cmluZy5weQ==) | `97.79% <81.25%> (-1.32%)` | :arrow_down: |
| [xknx/io/knxip\_interface.py](https://codecov.io/gh/XKNX/xknx/pull/1074/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9rbnhpcF9pbnRlcmZhY2UucHk=) | `85.02% <90.00%> (-0.30%)` | :arrow_down: |


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index ad369c23..a46847b5 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -11,17 +11,24 @@ nav_order: 2
 ### Interface changes
 
 - Removed `own_address` from `XKNX` class. `ConnectionConfig` `individual_address` can be used to set a source address for routing instead.
+  If set for a secure tunnelling connection, a tunnel with this IA will be read from the knxkeys file.
 - Disable TelegramQueue rate limiting by default.
 - Separate discovery multicast group from routing group. Add `multicast_group` and `multicast_port` `ConnectionConfig` parameters.
 
 ### Connection and Discovery
 
+- Use manually configured IP secure tunnel password over loading it from keyring.
 - GatewayScanFilter now also matches secure enabled gateways by default. The `secure` argument as been replaced by `secure_tunnelling` and `secure_routing` arguments. When multiple methods are `True` a gateway is matched if one of them is supported. Non-secure methods don't match if secure is required for that gateway.
 - Self description queries more information from Core v2 devices via SearchRequestExtended.
 
 ### Features
 
 - Add support for python 3.11
+- Add methods to Keyring to get interfaces by individual address (host or tunnel).
+
+### Internal
+
+- Remove `InterfaceWithUserIdNotFound` and `InvalidSignature` errors in favor of `InvalidSecureConfiguration`.
 
 ### Management
 
diff --git a/xknx/exceptions/__init__.py b/xknx/exceptions/__init__.py
index 94f1b1d1..8ed15477 100644
--- a/xknx/exceptions/__init__.py
+++ b/xknx/exceptions/__init__.py
@@ -9,9 +9,7 @@ from .exception import (
     CouldNotParseTelegram,
     DeviceIllegalValue,
     IncompleteKNXIPFrame,
-    InterfaceWithUserIdNotFound,
     InvalidSecureConfiguration,
-    InvalidSignature,
     KNXSecureValidationError,
     ManagementConnectionError,
     ManagementConnectionRefused,
@@ -34,9 +32,7 @@ __all__ = [
     "ManagementConnectionRefused",
     "ManagementConnectionTimeout",
     "IncompleteKNXIPFrame",
-    "InterfaceWithUserIdNotFound",
     "InvalidSecureConfiguration",
-    "InvalidSignature",
     "KNXSecureValidationError",
     "SecureException",
     "TunnellingAckError",
diff --git a/xknx/exceptions/exception.py b/xknx/exceptions/exception.py
index 9057d2c4..e03f6887 100644
--- a/xknx/exceptions/exception.py
+++ b/xknx/exceptions/exception.py
@@ -159,14 +159,6 @@ class SecureException(XKNXException):
     """Exception class for ip secure handling."""
 
 
-class InvalidSignature(SecureException):
-    """Exception class used when the signature of a knxkeys file is invalid."""
-
-
-class InterfaceWithUserIdNotFound(SecureException):
-    """Exception class used when requesting an interface with a user id that does not exist."""
-
-
 class InvalidSecureConfiguration(SecureException):
     """Exception class used when the secure configuration is invalid."""
 
diff --git a/xknx/io/connection.py b/xknx/io/connection.py
index 97b8109b..39aab130 100644
--- a/xknx/io/connection.py
+++ b/xknx/io/connection.py
@@ -30,7 +30,9 @@ class ConnectionConfig:
         * ROUTING use KNX/IP multicast routing.
         * TUNNELING connect to a specific KNX/IP tunneling device via UDP.
         * TUNNELING_TCP connect to a specific KNX/IP tunneling v2 device via TCP.
-    * individual address: the individual address used as source address for routing.
+    * individual address:
+        * ROUTING the individual address used as source address for routing
+        * SECURE TUNNELING use a specific tunnel endpoint from the knxkeys file
     * local_ip: Local ip of the interface though which KNXIPInterface should connect.
     * gateway_ip: IP of KNX/IP tunneling device.
     * gateway_port: Port of KNX/IP tunneling device.
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 068dd401..0301f223 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -16,18 +16,18 @@ from typing import TYPE_CHECKING, TypeVar
 
 from xknx.exceptions import (
     CommunicationError,
-    InterfaceWithUserIdNotFound,
     InvalidSecureConfiguration,
     XKNXException,
 )
 from xknx.io import util
-from xknx.secure import load_key_ring
+from xknx.secure.keyring import XMLInterface, load_key_ring
 from xknx.telegram import IndividualAddress, Telegram
 
 from .connection import ConnectionConfig, ConnectionType
 from .const import DEFAULT_INDIVIDUAL_ADDRESS, DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT
 from .gateway_scanner import GatewayDescriptor, GatewayScanner
 from .routing import Routing, SecureRouting
+from .self_description import request_description
 from .tunnel import SecureTunnel, TCPTunnel, UDPTunnel, _Tunnel
 
 if TYPE_CHECKING:
@@ -78,40 +78,8 @@ class KNXIPInterface:
                 multicast_group=self.connection_config.multicast_group,
                 multicast_port=self.connection_config.multicast_port,
             )
-        elif (
-            self.connection_config.connection_type == ConnectionType.ROUTING_SECURE
-            and self.connection_config.secure_config is not None
-        ):
-            secure_config = self.connection_config.secure_config
-            backbone_key = secure_config.backbone_key
-            latency_ms = secure_config.latency_ms
-            multicast_group = self.connection_config.multicast_group
-            if (
-                secure_config.knxkeys_file_path is not None
-                and secure_config.knxkeys_password is not None
-            ):
-                keyring = load_key_ring(
-                    secure_config.knxkeys_file_path, secure_config.knxkeys_password
-                )
-                if keyring.backbone is None:
-                    raise InvalidSecureConfiguration(
-                        "No backbone key found in knxkeys file"
-                    )
-                backbone_key = backbone_key or keyring.backbone.decrypted_key
-                latency_ms = latency_ms or keyring.backbone.latency
-                if keyring.backbone.multicast_address:
-                    multicast_group = keyring.backbone.multicast_address
-            if not backbone_key:
-                raise InvalidSecureConfiguration(
-                    "No backbone key found in secure configuration"
-                )
-            await self._start_secure_routing(
-                backbone_key,
-                latency_ms=latency_ms,
-                local_ip=self.connection_config.local_ip,
-                multicast_group=multicast_group,
-                multicast_port=self.connection_config.multicast_port,
-            )
+        elif self.connection_config.connection_type == ConnectionType.ROUTING_SECURE:
+            await self._start_secure_routing()
         elif (
             self.connection_config.connection_type == ConnectionType.TUNNELING
             and self.connection_config.gateway_ip is not None
@@ -132,48 +100,10 @@ class KNXIPInterface:
             self.connection_config.connection_type
             == ConnectionType.TUNNELING_TCP_SECURE
             and self.connection_config.gateway_ip is not None
-            and self.connection_config.secure_config is not None
         ):
-            secure_config = self.connection_config.secure_config
-            user_id: int
-            user_password: str
-            device_authentication_password: str | None
-            if (
-                secure_config.knxkeys_file_path is not None
-                and secure_config.knxkeys_password is not None
-            ):
-                keyring = load_key_ring(
-                    secure_config.knxkeys_file_path, secure_config.knxkeys_password
-                )
-                if secure_config.user_id is not None:
-                    user_id = secure_config.user_id
-                    interface = keyring.get_interface_by_user_id(user_id)
-                    if interface is None:
-                        raise InterfaceWithUserIdNotFound()
-
-                    user_password = interface.decrypted_password
-                    device_authentication_password = interface.decrypted_authentication
-                else:
-                    interface = keyring.interfaces[0]
-                    user_id = interface.user_id
-                    user_password = interface.decrypted_password
-                    device_authentication_password = interface.decrypted_authentication
-            else:
-                user_id = secure_config.user_id or 2
-                if secure_config.user_password is None:
-                    raise InvalidSecureConfiguration()
-
-                user_password = secure_config.user_password
-                device_authentication_password = (
-                    secure_config.device_authentication_password
-                )
-
             await self._start_secure_tunnelling_tcp(
                 gateway_ip=self.connection_config.gateway_ip,
                 gateway_port=self.connection_config.gateway_port,
-                user_id=user_id,
-                user_password=user_password,
-                device_authentication_password=device_authentication_password,
             )
         else:
             await self._start_automatic()
@@ -186,14 +116,18 @@ class KNXIPInterface:
             scan_filter=self.connection_config.scan_filter,
         ).async_scan():
             try:
-                if (
-                    gateway.supports_tunnelling_tcp
-                    and not gateway.tunnelling_requires_secure
-                ):
-                    await self._start_tunnelling_tcp(
-                        gateway_ip=gateway.ip_addr,
-                        gateway_port=gateway.port,
-                    )
+                if gateway.supports_tunnelling_tcp:
+                    if gateway.tunnelling_requires_secure:
+                        await self._start_secure_tunnelling_tcp(
+                            gateway_ip=gateway.ip_addr,
+                            gateway_port=gateway.port,
+                            gateway_descriptor=gateway,
+                        )
+                    else:
+                        await self._start_tunnelling_tcp(
+                            gateway_ip=gateway.ip_addr,
+                            gateway_port=gateway.port,
+                        )
                 elif (
                     gateway.supports_tunnelling
                     and not gateway.tunnelling_requires_secure
@@ -241,11 +175,46 @@ class KNXIPInterface:
         self,
         gateway_ip: str,
         gateway_port: int,
-        user_id: int,
-        user_password: str,
-        device_authentication_password: str | None,
+        gateway_descriptor: GatewayDescriptor | None = None,
     ) -> None:
-        """Start KNX/IP TCP tunnel."""
+        """Start KNX/IP Secure TCP tunnel."""
+        if (secure_config := self.connection_config.secure_config) is None:
+            raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig")
+
+        if (
+            secure_config.user_id is not None
+            and secure_config.user_password is not None
+        ):
+            user_id = secure_config.user_id
+            user_password = secure_config.user_password
+            device_authentication_password = (
+                secure_config.device_authentication_password
+            )
+        elif (
+            secure_config.knxkeys_file_path is not None
+            and secure_config.knxkeys_password is not None
+        ):
+            _gateway = gateway_descriptor or await request_description(
+                gateway_ip=gateway_ip, gateway_port=gateway_port
+            )
+            xml_interface = self._get_tunnel_interface_from_keyfile(
+                keyfile_path=secure_config.knxkeys_file_path,
+                keyfile_password=secure_config.knxkeys_password,
+                gateway_descriptor=_gateway,
+                config_user_id=secure_config.user_id,
+            )
+            user_id = xml_interface.user_id
+            if (_user_password := xml_interface.decrypted_password) is None:
+                raise InvalidSecureConfiguration(
+                    f"No password found for tunnel {xml_interface.individual_address} user_id {user_id}"
+                )
+            user_password = _user_password
+            device_authentication_password = xml_interface.decrypted_authentication
+        else:
+            raise InvalidSecureConfiguration(
+                "No `user_id` or `knxkeys_file_path` and password found in secure configuration"
+            )
+
         util.validate_ip(gateway_ip, address_name="Gateway IP address")
         logger.debug(
             "Starting secure tunnel to %s:%s over TCP",
@@ -265,6 +234,61 @@ class KNXIPInterface:
         )
         await self._interface.connect()
 
+    def _get_tunnel_interface_from_keyfile(
+        self,
+        keyfile_path: str,
+        keyfile_password: str,
+        gateway_descriptor: GatewayDescriptor,
+        config_user_id: int | None = None,
+    ) -> XMLInterface:
+        """
+        Get tunnel interface from keyfile.
+
+        Precedence: configured individual address > configured user id > first free tunnel interface
+        """
+        keyring = load_key_ring(
+            keyfile_path,
+            keyfile_password,
+        )
+        if _ia := self.connection_config.individual_address:
+            if xml_interface := keyring.get_tunnel_interface_by_individual_address(_ia):
+                return xml_interface
+            raise InvalidSecureConfiguration(
+                f"Interface with individual address {_ia} not found in keyfile"
+            )
+
+        if not (host_ia := gateway_descriptor.individual_address):
+            raise InvalidSecureConfiguration(
+                "Gateway does not provide individual address"
+            )
+        if config_user_id:
+            if xml_interface := keyring.get_tunnel_interface_by_host_and_user_id(
+                host=host_ia, user_id=config_user_id
+            ):
+                return xml_interface
+            raise InvalidSecureConfiguration(
+                f"Interface of host {host_ia} with user_id {config_user_id} not found in keyfile"
+            )
+
+        _free_slots = [
+            tunnel_ia
+            for tunnel_ia, slot in gateway_descriptor.tunnelling_slots.items()
+            if slot.usable and slot.free
+        ]
+        if not _free_slots:
+            raise InvalidSecureConfiguration(
+                f"No free tunnelling slots found on gateway {host_ia}"
+            )
+        for _tunnel_ia in _free_slots:
+            if xml_interface := keyring.get_tunnel_interface_by_individual_address(
+                tunnelling_slot=_tunnel_ia
+            ):
+                return xml_interface
+
+        raise InvalidSecureConfiguration(
+            "No credentials for any free tunnelling slot found in keyfile"
+        )
+
     async def _start_tunnelling_udp(
         self,
         gateway_ip: str,
@@ -339,14 +363,41 @@ class KNXIPInterface:
 
     async def _start_secure_routing(
         self,
-        backbone_key: bytes,
         latency_ms: int | None = None,
         local_ip: str | None = None,
         multicast_group: str = DEFAULT_MCAST_GRP,
         multicast_port: int = DEFAULT_MCAST_PORT,
     ) -> None:
         """Start KNX/IP Routing."""
-        local_ip = local_ip or await util.get_default_local_ip()
+        if self.connection_config.secure_config is None:
+            raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig")
+        backbone_key = self.connection_config.secure_config.backbone_key
+        latency_ms = self.connection_config.secure_config.latency_ms
+        multicast_group = self.connection_config.multicast_group
+        multicast_port = self.connection_config.multicast_port
+        if (
+            self.connection_config.secure_config.knxkeys_file_path is not None
+            and self.connection_config.secure_config.knxkeys_password is not None
+        ):
+            keyring = load_key_ring(
+                self.connection_config.secure_config.knxkeys_file_path,
+                self.connection_config.secure_config.knxkeys_password,
+            )
+            if keyring.backbone is None:
+                raise InvalidSecureConfiguration(
+                    "No backbone key found in knxkeys file"
+                )
+            # default to manually configured values
+            backbone_key = backbone_key or keyring.backbone.decrypted_key
+            latency_ms = latency_ms or keyring.backbone.latency
+            if keyring.backbone.multicast_address:
+                multicast_group = keyring.backbone.multicast_address
+        if not backbone_key:
+            raise InvalidSecureConfiguration(
+                "No backbone key found in secure configuration"
+            )
+
+        local_ip = self.connection_config.local_ip or await util.get_default_local_ip()
         if local_ip is None:
             raise XKNXException("No network interface found.")
         util.validate_ip(local_ip, address_name="Local IP address")
diff --git a/xknx/io/self_description.py b/xknx/io/self_description.py
index 24096891..fe112d74 100644
--- a/xknx/io/self_description.py
+++ b/xknx/io/self_description.py
@@ -37,14 +37,16 @@ async def request_description(
     local_ip: str | None = None,
     local_port: int = 0,
     route_back: bool = False,
-) -> GatewayDescriptor | None:
+) -> GatewayDescriptor:
     """Set up a UDP transport to request a description from a KNXnet/IP device."""
     local_ip = local_ip or util.find_local_ip(gateway_ip)
     if local_ip is None:
         # Fall back to default interface and use route back
         local_ip = await util.get_default_local_ip(gateway_ip)
         if local_ip is None:
-            return None
+            raise CommunicationError(
+                f"No network interface found to request gateway info from {gateway_ip}:{gateway_port}"
+            )
         route_back = True
 
     transport = UDPTransport(
@@ -55,8 +57,9 @@ async def request_description(
     try:
         await transport.connect()
     except OSError as err:
-        logger.warning("Could not setup socket: %s", err)
-        return None
+        raise CommunicationError(
+            "Could not setup socket to request gatway info"
+        ) from err
     else:
         local_hpai: HPAI
         if route_back:
@@ -72,7 +75,9 @@ async def request_description(
         await description_query.start()
         gateway = description_query.gateway_descriptor
         if gateway is None:
-            return None
+            raise CommunicationError(
+                f"Could not fetch gateway info from {gateway_ip}:{gateway_port}"
+            )
         if gateway.core_version >= 2:
             search_extended_query = SearchExtendedQuery(
                 transport=transport,
@@ -80,7 +85,10 @@ async def request_description(
             )
             await search_extended_query.start()
             gateway = search_extended_query.gateway_descriptor
-
+            if gateway is None:
+                raise CommunicationError(
+                    f"Could not fetch extended gateway info from {gateway_ip}:{gateway_port}"
+                )
         return gateway
     finally:
         transport.stop()
@@ -162,7 +170,11 @@ class DescriptionQuery(_SelfDescriptionQuery):
 
 
 class SearchExtendedQuery(_SelfDescriptionQuery):
-    """Class to send a SearchRequestExtended and wait for SearchResponseExtended to a single device."""
+    """
+    Class to send a SearchRequestExtended and wait for SearchResponseExtended to a single device.
+
+    Does only work with UDP transports.
+    """
 
     expected_response_class = SearchResponseExtended
 
diff --git a/xknx/secure/keyring.py b/xknx/secure/keyring.py
index e2d3e0c6..3225a3c0 100644
--- a/xknx/secure/keyring.py
+++ b/xknx/secure/keyring.py
@@ -18,7 +18,7 @@ from cryptography.hazmat.primitives import hashes
 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
 
-from xknx.exceptions.exception import InvalidSecureConfiguration, InvalidSignature
+from xknx.exceptions.exception import InvalidSecureConfiguration
 from xknx.telegram import GroupAddress, IndividualAddress
 
 from .util import sha256_hash
@@ -79,8 +79,8 @@ class XMLInterface(AttributeReader):
     host: IndividualAddress
     user_id: int
     password: str
-    decrypted_password: str
-    decrypted_authentication: str
+    decrypted_password: str | None = None
+    decrypted_authentication: str | None = None
     individual_address: IndividualAddress
     authentication: str
     group_addresses: list[XMLAssignedGroupAddress] = []
@@ -107,23 +107,29 @@ class XMLInterface(AttributeReader):
     ) -> None:
         """Decryt attributes."""
 
-        if self.password is not None:
-            self.decrypted_password = extract_password(
+        self.decrypted_password = (
+            extract_password(
                 decrypt_aes128cbc(
                     base64.b64decode(self.password),
                     password_hash,
                     initialization_vector,
                 )
             )
+            if self.password is not None
+            else None
+        )
 
-        if self.authentication is not None:
-            self.decrypted_authentication = extract_password(
+        self.decrypted_authentication = (
+            extract_password(
                 decrypt_aes128cbc(
                     base64.b64decode(self.authentication),
                     password_hash,
                     initialization_vector,
                 )
             )
+            if self.authentication is not None
+            else None
+        )
 
 
 class XMLBackbone(AttributeReader):
@@ -172,10 +178,10 @@ class XMLDevice(AttributeReader):
 
     individual_address: IndividualAddress
     tool_key: str
-    decrypted_tool_key: bytes
+    decrypted_tool_key: bytes | None = None
     management_password: str
-    decrypted_management_password: str
-    decrypted_authentication: str
+    decrypted_management_password: str | None = None
+    decrypted_authentication: str | None = None
     authentication: str
     sequence_number: int
 
@@ -198,28 +204,38 @@ class XMLDevice(AttributeReader):
         self, password_hash: bytes, initialization_vector: bytes
     ) -> None:
         """Decrypt attributes."""
-        if self.tool_key is not None:
-            self.decrypted_tool_key = decrypt_aes128cbc(
+
+        self.decrypted_tool_key = (
+            decrypt_aes128cbc(
                 base64.b64decode(self.tool_key), password_hash, initialization_vector
             )
+            if self.tool_key is not None
+            else None
+        )
 
-        if self.authentication is not None:
-            self.decrypted_authentication = extract_password(
+        self.decrypted_authentication = (
+            extract_password(
                 decrypt_aes128cbc(
                     base64.b64decode(self.authentication),
                     password_hash,
                     initialization_vector,
                 )
             )
+            if self.authentication is not None
+            else None
+        )
 
-        if self.management_password is not None:
-            self.decrypted_management_password = extract_password(
+        self.decrypted_management_password = (
+            extract_password(
                 decrypt_aes128cbc(
                     base64.b64decode(self.management_password),
                     password_hash,
                     initialization_vector,
                 )
             )
+            if self.management_password is not None
+            else None
+        )
 
 
 class Keyring(AttributeReader):
@@ -248,13 +264,42 @@ class Keyring(AttributeReader):
 
         return None
 
-    def get_interface_by_user_id(self, user_id: int) -> XMLInterface | None:
-        """Get the interface with the given user id."""
-        for interface in self.interfaces:
-            if interface.user_id == user_id:
-                return interface
+    def get_tunnel_interfaces_by_host(
+        self, host: IndividualAddress
+    ) -> list[XMLInterface]:
+        """Get all tunnel interfaces of a given host individual address."""
+        return [
+            tunnel
+            for tunnel in self.interfaces
+            if tunnel.type is InterfaceType.TUNNELING and tunnel.host == host
+        ]
+
+    def get_tunnel_interface_by_host_and_user_id(
+        self, host: IndividualAddress, user_id: int
+    ) -> XMLInterface | None:
+        """Get the tunnel interface with the given host and user id."""
+        return next(
+            (
+                tunnel
+                for tunnel in self.get_tunnel_interfaces_by_host(host)
+                if tunnel.user_id == user_id
+            ),
+            None,
+        )
 
-        return None
+    def get_tunnel_interface_by_individual_address(
+        self, tunnelling_slot: IndividualAddress
+    ) -> XMLInterface | None:
+        """Get the interface with the given tunneling address."""
+        return next(
+            (
+                tunnel
+                for tunnel in self.interfaces
+                if tunnel.type is InterfaceType.TUNNELING
+                and tunnel.individual_address == tunnelling_slot
+            ),
+            None,
+        )
 
     def parse_xml(self, node: Document) -> None:
         """Parse all needed attributes from the given node map."""
@@ -308,7 +353,7 @@ def load_key_ring(path: str, password: str, validate_signature: bool = True) ->
 
     if validate_signature:
         if not verify_keyring_signature(path, password):
-            raise InvalidSignature()
+            raise InvalidSecureConfiguration("Invalid signature for keyring file")
 
     keyring: Keyring = Keyring()
     try:


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index d3893504..4752be5e 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -1,13 +1,13 @@
 -r production.txt
 pre-commit==2.20.0
 isort==5.10.1
-flake8==5.0.4
-flake8-isort==5.0.0
+flake8==6.0.0
+flake8-isort==5.0.3
 pydocstyle==6.1.1
-pylint==2.15.5
+pylint==2.15.6
 pytest==7.2.0
 pytest-asyncio==0.20.2
 pytest-cov==4.0.0
-setuptools==65.5.1
+setuptools==65.6.3
 tox==3.27.1
 mypy==0.991
diff --git a/test/io_tests/knxip_interface_test.py b/test/io_tests/knxip_interface_test.py
index 8088c189..5f23188c 100644
--- a/test/io_tests/knxip_interface_test.py
+++ b/test/io_tests/knxip_interface_test.py
@@ -6,11 +6,7 @@ from unittest.mock import DEFAULT, Mock, patch
 import pytest
 
 from xknx import XKNX
-from xknx.exceptions.exception import (
-    CommunicationError,
-    InterfaceWithUserIdNotFound,
-    InvalidSecureConfiguration,
-)
+from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration
 from xknx.io import (
     ConnectionConfig,
     ConnectionType,
@@ -20,11 +16,15 @@ from xknx.io import (
 )
 from xknx.io.routing import Routing, SecureGroup, SecureRouting
 from xknx.io.tunnel import SecureTunnel, TCPTunnel, UDPTunnel
+from xknx.knxip.dib import TunnelingSlotStatus
+from xknx.telegram import IndividualAddress
 
 
 class TestKNXIPInterface:
     """Test class for KNX interface objects."""
 
+    knxkeys_file = os.path.join(os.path.dirname(__file__), "resources/testcase.knxkeys")
+
     def setup_method(self):
         """Set up test class."""
         # pylint: disable=attribute-defined-outside-init
@@ -209,32 +209,30 @@ class TestKNXIPInterface:
             disconnect_routing_mock.assert_called_once_with()
             assert interface._interface is None
 
-    async def test_start_secure_connection_knx_keys(self):
-        """Test starting a secure connection from a knxkeys file."""
+    async def test_start_secure_connection_knx_keys_user_id(self):
+        """Test starting a secure connection from a knxkeys file with user_id."""
         gateway_ip = "192.168.1.1"
-        knxkeys_file = os.path.join(
-            os.path.dirname(__file__), "resources/testcase.knxkeys"
-        )
         connection_config = ConnectionConfig(
             connection_type=ConnectionType.TUNNELING_TCP_SECURE,
             gateway_ip=gateway_ip,
             secure_config=SecureConfig(
-                user_id=3, knxkeys_file_path=knxkeys_file, knxkeys_password="password"
+                user_id=3,
+                knxkeys_file_path=self.knxkeys_file,
+                knxkeys_password="password",
             ),
         )
+        gateway_description = GatewayDescriptor(
+            ip_addr=gateway_ip,
+            port=3671,
+            supports_tunnelling_tcp=True,
+            supports_secure=True,
+            individual_address=IndividualAddress("1.0.0"),
+        )
+        gateway_description.tunnelling_requires_secure = True
         with patch(
-            "xknx.io.KNXIPInterface._start_secure_tunnelling_tcp"
-        ) as start_secure_tunnel:
-            interface = knx_interface_factory(self.xknx, connection_config)
-            await interface.start()
-            start_secure_tunnel.assert_called_once_with(
-                gateway_ip="192.168.1.1",
-                gateway_port=3671,
-                user_id=3,
-                user_password="user1",
-                device_authentication_password="authenticationcode",
-            )
-        with patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
+            "xknx.io.knxip_interface.request_description",
+            return_value=gateway_description,
+        ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
             assert isinstance(interface._interface, SecureTunnel)
@@ -254,32 +252,76 @@ class TestKNXIPInterface:
             )
             connect_secure.assert_called_once_with()
 
-    async def test_start_secure_connection_knx_keys_first_interface(self):
-        """Test starting a secure connection from a knxkeys file."""
+    async def test_start_secure_connection_knx_keys_ia(self):
+        """Test starting a secure connection from a knxkeys file with individual address."""
         gateway_ip = "192.168.1.1"
-        knxkeys_file = os.path.join(
-            os.path.dirname(__file__), "resources/testcase.knxkeys"
-        )
         connection_config = ConnectionConfig(
             connection_type=ConnectionType.TUNNELING_TCP_SECURE,
             gateway_ip=gateway_ip,
+            individual_address="1.0.12",
             secure_config=SecureConfig(
-                knxkeys_file_path=knxkeys_file, knxkeys_password="password"
+                knxkeys_file_path=self.knxkeys_file, knxkeys_password="password"
             ),
         )
+        # result of request_description is currently not used when IA is defined
         with patch(
-            "xknx.io.KNXIPInterface._start_secure_tunnelling_tcp"
-        ) as start_secure_tunnel:
+            "xknx.io.knxip_interface.request_description",
+        ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
-            start_secure_tunnel.assert_called_once_with(
-                gateway_ip="192.168.1.1",
-                gateway_port=3671,
-                user_id=3,
-                user_password="user1",
-                device_authentication_password="authenticationcode",
+            assert isinstance(interface._interface, SecureTunnel)
+            assert interface._interface.gateway_ip == gateway_ip
+            assert interface._interface.gateway_port == 3671
+            assert interface._interface.auto_reconnect is True
+            assert interface._interface.auto_reconnect_wait == 3
+            assert interface._interface._user_id == 5
+            assert interface._interface._user_password == "user3"
+            assert (
+                interface._interface._device_authentication_password
+                == "authenticationcode"
             )
-        with patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
+            assert (  # pylint: disable=comparison-with-callable
+                interface._interface.telegram_received_callback
+                == interface.telegram_received
+            )
+            connect_secure.assert_called_once_with()
+
+    async def test_start_secure_connection_knx_keys_first_interface(self):
+        """Test starting a secure connection from a knxkeys file."""
+        gateway_ip = "192.168.1.1"
+        connection_config = ConnectionConfig(
+            connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+            gateway_ip=gateway_ip,
+            secure_config=SecureConfig(
+                knxkeys_file_path=self.knxkeys_file, knxkeys_password="password"
+            ),
+        )
+        gateway_description = GatewayDescriptor(
+            ip_addr=gateway_ip,
+            port=3671,
+            supports_tunnelling_tcp=True,
+            supports_secure=True,
+            individual_address=IndividualAddress("1.0.0"),
+        )
+        gateway_description.tunnelling_requires_secure = True
+        gateway_description.tunnelling_slots = {
+            IndividualAddress("1.0.1"): TunnelingSlotStatus(
+                usable=True, authorized=False, free=False
+            ),
+            IndividualAddress("1.0.11"): TunnelingSlotStatus(
+                usable=True, authorized=False, free=True
+            ),
+            IndividualAddress("1.0.12"): TunnelingSlotStatus(
+                usable=True, authorized=False, free=True
+            ),
+            IndividualAddress("1.0.13"): TunnelingSlotStatus(
+                usable=True, authorized=False, free=True
+            ),
+        }
+        with patch(
+            "xknx.io.knxip_interface.request_description",
+            return_value=gateway_description,
+        ), patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
             assert isinstance(interface._interface, SecureTunnel)
@@ -287,8 +329,8 @@ class TestKNXIPInterface:
             assert interface._interface.gateway_port == 3671
             assert interface._interface.auto_reconnect is True
             assert interface._interface.auto_reconnect_wait == 3
-            assert interface._interface._user_id == 3
-            assert interface._interface._user_password == "user1"
+            assert interface._interface._user_id == 4
+            assert interface._interface._user_password == "user2"
             assert (
                 interface._interface._device_authentication_password
                 == "authenticationcode"
@@ -311,18 +353,6 @@ class TestKNXIPInterface:
                 user_password="user1",
             ),
         )
-        with patch(
-            "xknx.io.KNXIPInterface._start_secure_tunnelling_tcp"
-        ) as start_secure_tunnel:
-            interface = knx_interface_factory(self.xknx, connection_config)
-            await interface.start()
-            start_secure_tunnel.assert_called_once_with(
-                gateway_ip="192.168.1.1",
-                gateway_port=3671,
-                user_id=3,
-                user_password="user1",
-                device_authentication_password="authenticationcode",
-            )
         with patch("xknx.io.tunnel.SecureTunnel.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
@@ -343,20 +373,57 @@ class TestKNXIPInterface:
             )
             connect_secure.assert_called_once_with()
 
-    async def test_invalid_user_id_secure_error(self):
-        """Test ip secure."""
-        gateway_ip = "192.168.1.1"
-        knxkeys_file = os.path.join(
-            os.path.dirname(__file__), "resources/testcase.knxkeys"
-        )
-        connection_config = ConnectionConfig(
-            connection_type=ConnectionType.TUNNELING_TCP_SECURE,
-            gateway_ip=gateway_ip,
-            secure_config=SecureConfig(
-                user_id=12, knxkeys_file_path=knxkeys_file, knxkeys_password="password"
+    @pytest.mark.parametrize(
+        "connection_config",
+        [
+            ConnectionConfig(  # invalid user_id
+                connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+                gateway_ip="192.168.1.1",
+                secure_config=SecureConfig(
+                    user_id=12,
+                    knxkeys_file_path=knxkeys_file,
+                    knxkeys_password="password",
+                ),
+            ),
+            ConnectionConfig(  # invalid IA
+                connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+                gateway_ip="192.168.1.1",
+                individual_address="9.9.9",
+                secure_config=SecureConfig(
+                    knxkeys_file_path=knxkeys_file,
+                    knxkeys_password="password",
+                ),
+            ),
+            ConnectionConfig(  # no secure_config
+                connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+                gateway_ip="192.168.1.1",
+                individual_address="9.9.9",
+            ),
+            ConnectionConfig(  # no password
+                connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+                gateway_ip="192.168.1.1",
+                secure_config=SecureConfig(
+                    user_id=3,
+                    knxkeys_file_path=knxkeys_file,
+                ),
             ),
+        ],
+    )
+    async def test_invalid_secure_error(self, connection_config):
+        """Test ip secure invalid configurations."""
+        gateway_ip = "192.168.1.1"
+        gateway_description = GatewayDescriptor(
+            ip_addr=gateway_ip,
+            port=3671,
+            supports_tunnelling_tcp=True,
+            supports_secure=True,
+            individual_address=IndividualAddress("1.0.0"),
         )
-        with pytest.raises(InterfaceWithUserIdNotFound):
+        gateway_description.tunnelling_requires_secure = True
+        with patch(
+            "xknx.io.knxip_interface.request_description",
+            return_value=gateway_description,
+        ), pytest.raises(InvalidSecureConfiguration):
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
 
@@ -377,27 +444,12 @@ class TestKNXIPInterface:
     async def test_start_secure_routing_knx_keys(self):
         """Test starting a secure routing connection from a knxkeys file."""
         backbone_key = bytes.fromhex("cf89fd0f18f4889783c7ef44ee1f5e14")
-        knxkeys_file = os.path.join(
-            os.path.dirname(__file__), "resources/testcase.knxkeys"
-        )
         connection_config = ConnectionConfig(
             connection_type=ConnectionType.ROUTING_SECURE,
             secure_config=SecureConfig(
-                knxkeys_file_path=knxkeys_file, knxkeys_password="password"
+                knxkeys_file_path=self.knxkeys_file, knxkeys_password="password"
             ),
         )
-        with patch(
-            "xknx.io.KNXIPInterface._start_secure_routing"
-        ) as start_secure_routing:
-            interface = knx_interface_factory(self.xknx, connection_config)
-            await interface.start()
-            start_secure_routing.assert_called_once_with(
-                backbone_key,
-                latency_ms=1000,
-                local_ip=None,
-                multicast_group="224.0.23.12",
-                multicast_port=3671,
-            )
         with patch("xknx.io.routing.SecureRouting.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
@@ -423,18 +475,6 @@ class TestKNXIPInterface:
             connection_type=ConnectionType.ROUTING_SECURE,
             secure_config=SecureConfig(backbone_key=backbone_key_str, latency_ms=2000),
         )
-        with patch(
-            "xknx.io.KNXIPInterface._start_secure_routing"
-        ) as start_secure_routing:
-            interface = knx_interface_factory(self.xknx, connection_config)
-            await interface.start()
-            start_secure_routing.assert_called_once_with(
-                backbone_key,
-                latency_ms=2000,
-                local_ip=None,
-                multicast_group="224.0.23.12",
-                multicast_port=3671,
-            )
         with patch("xknx.io.routing.SecureRouting.connect") as connect_secure:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
diff --git a/test/secure_tests/keyring_test.py b/test/secure_tests/keyring_test.py
index 0e6b488e..302aea1f 100644
--- a/test/secure_tests/keyring_test.py
+++ b/test/secure_tests/keyring_test.py
@@ -3,9 +3,10 @@ import os
 
 import pytest
 
-from xknx.exceptions.exception import InvalidSecureConfiguration, InvalidSignature
+from xknx.exceptions.exception import InvalidSecureConfiguration
 from xknx.secure import Keyring, load_key_ring
 from xknx.secure.keyring import XMLDevice, XMLInterface, verify_keyring_signature
+from xknx.telegram import IndividualAddress
 
 
 class TestKeyRing:
@@ -19,13 +20,25 @@ class TestKeyRing:
         os.path.dirname(__file__), "resources/testcase.knxkeys"
     )
 
+    @staticmethod
+    def assert_interface(
+        keyring: Keyring, password: str, ia: IndividualAddress
+    ) -> None:
+        """Verify password for given user."""
+        matched = False
+        if interface := keyring.get_tunnel_interface_by_individual_address(ia):
+            matched = True
+            assert interface.decrypted_password == password
+
+        assert matched
+
     def test_load_keyring(self):
         """Test load keyring from knxkeys file."""
         keyring: Keyring = load_key_ring(self.keyring_test_file, "pwd")
-        TestKeyRing.assert_interface(keyring, "user4", 2)
-        TestKeyRing.assert_interface(keyring, "@zvI1G&_", 3)
-        TestKeyRing.assert_interface(keyring, "ZvDY-:g#", 4)
-        TestKeyRing.assert_interface(keyring, "user2", 5)
+        TestKeyRing.assert_interface(keyring, "user4", IndividualAddress("1.1.4"))
+        TestKeyRing.assert_interface(keyring, "@zvI1G&_", IndividualAddress("1.1.6"))
+        TestKeyRing.assert_interface(keyring, "ZvDY-:g#", IndividualAddress("1.1.7"))
+        TestKeyRing.assert_interface(keyring, "user2", IndividualAddress("1.1.2"))
         assert keyring.backbone.multicast_address == "224.0.23.12"
         assert keyring.backbone.latency == 1000
         assert keyring.backbone.decrypted_key == bytes.fromhex(
@@ -35,10 +48,10 @@ class TestKeyRing:
     def test_load_keyring_real(self):
         """Test load keyring from knxkeys file."""
         keyring: Keyring = load_key_ring(self.testcase_file, "password")
-        TestKeyRing.assert_interface(keyring, "user1", 3)
-        TestKeyRing.assert_interface(keyring, "user2", 4)
-        TestKeyRing.assert_interface(keyring, "user3", 5)
-        TestKeyRing.assert_interface(keyring, "user4", 6)
+        TestKeyRing.assert_interface(keyring, "user1", IndividualAddress("1.0.1"))
+        TestKeyRing.assert_interface(keyring, "user2", IndividualAddress("1.0.11"))
+        TestKeyRing.assert_interface(keyring, "user3", IndividualAddress("1.0.12"))
+        TestKeyRing.assert_interface(keyring, "user4", IndividualAddress("1.0.13"))
         assert keyring.devices[0].decrypted_management_password == "commissioning"
         assert keyring.backbone.multicast_address == "224.0.23.12"
         assert keyring.backbone.latency == 1000
@@ -58,7 +71,7 @@ class TestKeyRing:
 
     def test_invalid_signature(self):
         """Test invalid signature throws error."""
-        with pytest.raises(InvalidSignature):
+        with pytest.raises(InvalidSecureConfiguration):
             load_key_ring(self.testcase_file, "wrong_password")
 
     def test_raises_error(self):
@@ -67,13 +80,3 @@ class TestKeyRing:
             load_key_ring(
                 self.testcase_file, "wrong_password", validate_signature=False
             )
-
-    @staticmethod
-    def assert_interface(keyring: Keyring, password: str, user: int) -> None:
-        """Verify password for given user."""
-        matched = False
-        if interface := keyring.get_interface_by_user_id(user):
-            matched = True
-            assert interface.decrypted_password == password
-
-        assert matched