"""IPSecure is an abstraction for handling a KNXnet/IP Secure layer.""" from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import logging import random from typing import Final from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) from xknx.exceptions import ( CommunicationError, CouldNotParseKNXIP, IPSecureError, KNXSecureValidationError, ) from xknx.knxip import ( HPAI, KNXIPFrame, KNXIPServiceType, RoutingIndication, SecureWrapper, SessionResponse, SessionStatus, TimerNotify, ) from xknx.knxip.knxip_enum import SecureSessionStatusCode from xknx.secure.security_primitives import ( calculate_message_authentication_code_cbc, decrypt_ctr, derive_device_authentication_password, derive_user_password, encrypt_data_ctr, generate_ecdh_key_pair, ) from xknx.secure.util import bytes_xor, sha256_hash from xknx.util import asyncio_timeout from .const import SESSION_KEEPALIVE_RATE, XKNX_SERIAL_NUMBER from .request_response import Authenticate, Session from .transport import KNXIPTransport, TCPTransport, UDPTransport logger = logging.getLogger("xknx.log") knx_logger = logging.getLogger("xknx.knx") ip_secure_logger = logging.getLogger("xknx.ip_secure") COUNTER_0_HANDSHAKE = ( # used in SessionResponse and SessionAuthenticate b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00" ) MESSAGE_TAG_TUNNELLING = bytes.fromhex("00 00") # use 0x00 0x00 for tunneling class _IPSecureTransportLayer(ABC): """Abstract for Secure transport layer.""" __slots__ = () session_id: int _key: bytes @abstractmethod def get_sequence_information(self) -> bytes: """Return byte representation of sequence information.""" @abstractmethod def get_message_tag(self) -> bytes: """Return byte representation of message tag.""" def decrypt_frame(self, encrypted_frame: KNXIPFrame) -> KNXIPFrame: """Unwrap and verify KNX/IP frame from SecureWrapper.""" # TODO: get raw data from KNXIPFrame class directly instead of recalculating it with to_knx() # TODO: refactor so assert isn't needed (maybe subclass SecureWrapper from KNXIPFrame instead of being an attribute) assert isinstance(encrypted_frame.body, SecureWrapper) if encrypted_frame.body.secure_session_id != self.session_id: raise KNXSecureValidationError("Invalid secure session id") session_id_bytes = encrypted_frame.body.secure_session_id.to_bytes(2, "big") wrapper_header = encrypted_frame.header.to_knx() dec_frame, mac_tr = decrypt_ctr( key=self._key, counter_0=( encrypted_frame.body.sequence_information + encrypted_frame.body.serial_number + encrypted_frame.body.message_tag + b"\xff\x00" ), mac=encrypted_frame.body.message_authentication_code, payload=encrypted_frame.body.encrypted_data, ) mac_cbc = calculate_message_authentication_code_cbc( key=self._key, additional_data=wrapper_header + session_id_bytes, payload=dec_frame, block_0=( encrypted_frame.body.sequence_information + encrypted_frame.body.serial_number + encrypted_frame.body.message_tag + len(dec_frame).to_bytes(2, "big") ), ) if mac_cbc != mac_tr: raise KNXSecureValidationError( "Verification of message authentication code failed" ) knxipframe, _ = KNXIPFrame.from_knx(dec_frame) return knxipframe def encrypt_frame(self, plain_frame: KNXIPFrame) -> KNXIPFrame: """Wrap KNX/IP frame in SecureWrapper.""" try: sequence_information = self.get_sequence_information() except OverflowError as err: raise IPSecureError( "KNX IP Secure sequence counter overflow." "\nCongratulations! You've managed to overflow a counter designed to last about 9000 years! " "Before celebrating, please check for any malfunctioning devices or suspicious activity " "in your installation. Once you've ensured everything's safe, reset the secure session " "to restore normal operation." ) from err message_tag = self.get_message_tag() plain_payload = plain_frame.to_knx() # P payload_length = len(plain_payload) # Q # 6 KNXnet/IP header, 2 session_id, 6 sequence_number, 6 serial_number, 2 message_tag, 16 MAC = 38 total_length = 38 + payload_length # TODO: get header data and total_length from SecureWrapper class wrapper_header = bytes.fromhex("06 10 09 50") + total_length.to_bytes(2, "big") mac_cbc = calculate_message_authentication_code_cbc( key=self._key, additional_data=wrapper_header + self.session_id.to_bytes(2, "big"), payload=plain_payload, block_0=( sequence_information + XKNX_SERIAL_NUMBER + message_tag + payload_length.to_bytes(2, "big") ), ) encrypted_data, mac = encrypt_data_ctr( key=self._key, counter_0=( sequence_information + XKNX_SERIAL_NUMBER + message_tag + b"\xff\x00" ), mac_cbc=mac_cbc, payload=plain_payload, ) return KNXIPFrame.init_from_body( SecureWrapper( secure_session_id=self.session_id, sequence_information=sequence_information, serial_number=XKNX_SERIAL_NUMBER, message_tag=message_tag, encrypted_data=encrypted_data, message_authentication_code=mac, ) ) class SecureSession(TCPTransport, _IPSecureTransportLayer): """Class for handling a KNXnet/IP Secure tunnelling session.""" __slots__ = ( "_device_authentication_code", "_keepalive_task", "_key", "_peer_public_key", "_private_key", "_sequence_number", "_sequence_number_received", "_session_status_handler", "_user_password", "initialized", "public_key", "session_id", "user_id", ) def __init__( self, remote_addr: tuple[str, int], user_id: int, user_password: str, device_authentication_password: str | None = None, connection_lost_cb: Callable[[], None] | None = None, ) -> None: """Initialize SecureSession class.""" super().__init__( remote_addr=remote_addr, connection_lost_cb=connection_lost_cb, ) self._device_authentication_code: bytes | None = ( derive_device_authentication_password(device_authentication_password) if device_authentication_password else None ) self.user_id = user_id self._user_password = derive_user_password(user_password) self._private_key: X25519PrivateKey self.public_key: bytes self._peer_public_key: X25519PublicKey self._key: bytes # Session Key self.session_id: int self._sequence_number: int = 0 self._sequence_number_received: int = -1 self.initialized: bool = False self._keepalive_task: asyncio.Task[None] | None = None self._session_status_handler: KNXIPTransport.Callback | None = None async def connect(self) -> None: """Connect transport.""" await super().connect() self._private_key, self.public_key = generate_ecdh_key_pair() self._sequence_number = 0 self._sequence_number_received = -1 request_session = Session( transport=self, ecdh_client_public_key=self.public_key, ) await request_session.start() if request_session.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) # SessionAuthenticate and everything else after now shall be wrapped in SecureWrapper authenticate_mac = self.handshake(request_session.response) self.initialized = True request_authentication = Authenticate( transport=self, user_id=self.user_id, message_authentication_code=authenticate_mac, ) await request_authentication.start() if request_authentication.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) if ( # TODO: look for status in request/response and use `success` instead of response ? request_authentication.response.status != SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS ): raise IPSecureError( f"Secure session authentication failed: {request_authentication.response.status}" ) self._session_status_handler = self.register_callback( self._handle_session_status, [KNXIPServiceType.SESSION_STATUS] ) def handshake(self, session_response: SessionResponse) -> bytes: """ Handshake with device. Returns a SessionAuthenticate KNX/IP body. """ self._peer_public_key = X25519PublicKey.from_public_bytes( session_response.ecdh_server_public_key ) self.session_id = session_response.secure_session_id # verify SessionResponse MAC # TODO: get header data from actual KNX/IP frame response_header_data = bytes.fromhex("06 10 09 52 00 38") pub_keys_xor = bytes_xor( self.public_key, session_response.ecdh_server_public_key, ) if self._device_authentication_code: response_mac_cbc = calculate_message_authentication_code_cbc( key=self._device_authentication_code, additional_data=response_header_data + self.session_id.to_bytes(2, "big") + pub_keys_xor, # knx_ip_header + secure_session_id + bytes_xor(client_pub_key, server_pub_key) ) _, mac_tr = decrypt_ctr( key=self._device_authentication_code, counter_0=COUNTER_0_HANDSHAKE, mac=session_response.message_authentication_code, ) if mac_tr != response_mac_cbc: raise IPSecureError("SessionResponse MAC verification failed.") # calculate session key ecdh_shared_secret = self._private_key.exchange(self._peer_public_key) self._key = sha256_hash(ecdh_shared_secret)[:16] # generate SessionAuthenticate MAC authenticate_header_data = bytes.fromhex("06 10 09 53 00 18") authenticate_mac_cbc = calculate_message_authentication_code_cbc( key=self._user_password, additional_data=authenticate_header_data + bytes(1) # reserved + self.user_id.to_bytes(1, "big") + pub_keys_xor, block_0=bytes(16), ) _, authenticate_mac = encrypt_data_ctr( key=self._user_password, counter_0=COUNTER_0_HANDSHAKE, mac_cbc=authenticate_mac_cbc, ) return authenticate_mac def stop(self) -> None: """Stop session and transport.""" if self._session_status_handler: self.unregister_callback(self._session_status_handler) self._session_status_handler = None if self.transport and self.initialized: self.send( KNXIPFrame.init_from_body( SessionStatus(status=SecureSessionStatusCode.STATUS_CLOSE) ) ) self.stop_keepalive_task() self.initialized = False super().stop() def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None: """Handle KNXIP Frame and call all callbacks matching the service type ident.""" # TODO: disallow unencrypted frames with exceptions for discovery etc. eg. DescriptionResponse if isinstance(knxipframe.body, SecureWrapper): if not self.initialized: raise CouldNotParseKNXIP( "Received SecureWrapper while Secure session not initialized" ) new_sequence_number = int.from_bytes( knxipframe.body.sequence_information, "big" ) if not new_sequence_number > self._sequence_number_received: ip_secure_logger.warning( "Discarding SecureWrapper with invalid sequence number: %s", knxipframe, ) return try: knxipframe = self.decrypt_frame(knxipframe) except KNXSecureValidationError as err: ip_secure_logger.warning("Could not decrypt KNXIPFrame: %s", err) # Frame shall be discarded return except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported encrypted KNXIPFrame: %s", couldnotparseknxip.description, ) return self._sequence_number_received = new_sequence_number knx_logger.debug("Decrypted frame: %s", knxipframe) super().handle_knxipframe(knxipframe, source) async def _session_keepalive(self) -> None: """Keep session alive.""" await asyncio.sleep(SESSION_KEEPALIVE_RATE) self.send( KNXIPFrame.init_from_body( SessionStatus(status=SecureSessionStatusCode.STATUS_KEEPALIVE) ) ) def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" if self.initialized: knx_logger.debug("Encrypting frame: %s", knxipframe) knxipframe = self.encrypt_frame(plain_frame=knxipframe) # keepalive timer is started with first and reset with every other # SecureWrapper frame (including wrapped keepalive frames themselves) self.start_keepalive_task() # TODO: disallow sending unencrypted frames over non-initialized session with # exceptions for discovery and SessionRequest super().send(knxipframe, addr) def get_sequence_information(self) -> bytes: """Increment sequence number. Return byte representation of current sequence number.""" next_sn = self._sequence_number.to_bytes(6, "big") self._sequence_number += 1 return next_sn def get_message_tag(self) -> bytes: """Return byte representation of current message tag.""" return MESSAGE_TAG_TUNNELLING def start_keepalive_task(self) -> None: """Start or restart session keepalive task.""" if self._keepalive_task: self._keepalive_task.cancel() self._keepalive_task = asyncio.create_task(self._session_keepalive()) def stop_keepalive_task(self) -> None: """Stop keepalive task.""" if self._keepalive_task: self._keepalive_task.cancel() self._keepalive_task = None def _handle_session_status( self, knxipframe: KNXIPFrame, source: HPAI, transport: KNXIPTransport ) -> None: """Handle session status.""" assert isinstance(knxipframe.body, SessionStatus) if knxipframe.body.status in ( SecureSessionStatusCode.STATUS_CLOSE, SecureSessionStatusCode.STATUS_TIMEOUT, SecureSessionStatusCode.STATUS_UNAUTHENTICATED, ): logger.info("Secure session closed by server: %s.", knxipframe.body.status) if self.transport: # closing transport will call `asyncio.Protocol.connection_lost` # and its callback from SecureTunnel self.transport.close() class SecureGroup(UDPTransport, _IPSecureTransportLayer): """Class for secure KNXnet/IP multicast communication.""" __slots__ = ("_key", "secure_timer") session_id = 0 # Routing uses fixed session id 0 def __init__( self, local_addr: tuple[str, int], remote_addr: tuple[str, int], backbone_key: bytes, latency_ms: int = 1000, ) -> None: """Initialize SecureGroup class.""" super().__init__( local_addr=local_addr, remote_addr=remote_addr, multicast=True, ) self._key = backbone_key self.secure_timer = SecureSequenceTimer( backbone_key=backbone_key, latency_ms=latency_ms, transport_send=super().send, ) async def connect(self) -> None: """Connect transport.""" await super().connect() await self.secure_timer.synchronize() def stop(self) -> None: """Stop tasks and transport.""" self.secure_timer.stop() super().stop() def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None: """Handle KNXIP Frame and call all callbacks matching the service type ident.""" if isinstance(knxipframe.body, RoutingIndication): ip_secure_logger.info( "Discarding received unencrypted RoutingIndication: %s", knxipframe, ) return if isinstance(knxipframe.body, TimerNotify): self.secure_timer.handle_timer_notify(knxipframe.body) return if isinstance(knxipframe.body, SecureWrapper): if not self.secure_timer.timer_authenticated: ip_secure_logger.debug( "Discarding received SecureWrapper before timer synchronisazion finished: %s", knxipframe, ) return secure_wrapper = knxipframe.body try: knxipframe = self.decrypt_frame(knxipframe) except KNXSecureValidationError as err: ip_secure_logger.warning("Could not decrypt KNXIPFrame: %s", err) # Frame shall be discarded return except CouldNotParseKNXIP as couldnotparseknxip: knx_logger.debug( "Unsupported encrypted KNXIPFrame: %s", couldnotparseknxip.description, ) return if not self.secure_timer.validate_secure_wrapper(secure_wrapper): ip_secure_logger.warning( "Discarding SecureWrapper with invalid timer value: %s", secure_wrapper, ) return knx_logger.debug("Decrypted frame: %s", knxipframe) # handle decrypted frames and plain frames (e.g. SearchRequest for discovery) super().handle_knxipframe(knxipframe, source) def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" knx_logger.debug("Encrypting frame: %s", knxipframe) knxipframe = self.encrypt_frame(plain_frame=knxipframe) # TODO: disallow sending unencrypted frames over non-initialized session with # exceptions for discovery and SessionRequest super().send(knxipframe, addr) def get_sequence_information(self) -> bytes: """Return byte representation of current timer value.""" return self.secure_timer.get_for_outgoing_secure_wrapper().to_bytes(6, "big") def get_message_tag(self) -> bytes: """Return byte representation of current message tag.""" return random.randbytes(2) class SecureSequenceTimer: """ Class for holding and synchronizing the timer for secure sequence information. According to AN159 v06 KNXnet-IP Secure AS §2.2.2.3 Timer synchronizing """ __slots__ = ( "_backbone_key", "_clock_difference", "_expected_notify_handler", "_loop", "_notify_timer_handle", "_transport_send", "latency_tolerance_ms", "max_delay_time_follower_periodic_notify", "max_delay_time_follower_update_notify", "max_delay_time_keeper_periodic_notify", "max_delay_time_keeper_update_notify", "min_delay_time_follower_periodic_notify", "min_delay_time_follower_update_notify", "sched_update", "sync_latency_tolerance_ms", "timekeeper", "timer_authenticated", ) TIMER_NOTIFY_HEADER = bytes.fromhex("06 10 09 55 00 24") MIN_DELAY_TIME_KEEPER_PERIODIC_NOTIFY: Final = 10 # seconds MIN_DELAY_TIME_KEEPER_UPDATE_NOTIFY: Final = 0.1 # seconds SYNC_LATENCY_FRACTION: Final = 10 # 10% def __init__( self, backbone_key: bytes, latency_ms: int, transport_send: Callable[[KNXIPFrame, tuple[str, int] | None], None], ) -> None: """Initialize SecureSequenceTimer class.""" self._backbone_key = backbone_key self._clock_difference: int = 0 self._expected_notify_handler: ( tuple[bytes, asyncio.Future[int]] # message_tag, synchronization future | None ) = None self._loop = asyncio.get_running_loop() self._notify_timer_handle: asyncio.TimerHandle | None = None self._transport_send = transport_send self.sched_update: bool = False self.timekeeper: bool = False self.timer_authenticated: bool = False self.latency_tolerance_ms = latency_ms self.sync_latency_tolerance_ms: int = round( latency_ms / 100 * self.SYNC_LATENCY_FRACTION ) _sync_latency_tolerance_seconds = self.sync_latency_tolerance_ms / 1000 self.max_delay_time_keeper_periodic_notify = ( self.MIN_DELAY_TIME_KEEPER_PERIODIC_NOTIFY + _sync_latency_tolerance_seconds * 3 ) self.min_delay_time_follower_periodic_notify = ( self.max_delay_time_keeper_periodic_notify + _sync_latency_tolerance_seconds * 1 ) self.max_delay_time_follower_periodic_notify = ( self.min_delay_time_follower_periodic_notify + _sync_latency_tolerance_seconds * 10 ) self.max_delay_time_keeper_update_notify = ( self.MIN_DELAY_TIME_KEEPER_UPDATE_NOTIFY + _sync_latency_tolerance_seconds * 1 ) self.min_delay_time_follower_update_notify = ( self.max_delay_time_keeper_update_notify + _sync_latency_tolerance_seconds * 1 ) self.max_delay_time_follower_update_notify = ( self.min_delay_time_follower_update_notify + _sync_latency_tolerance_seconds * 10 ) def stop(self) -> None: """Cancel notify timer.""" if self._notify_timer_handle: self._notify_timer_handle.cancel() self._notify_timer_handle = None if self._expected_notify_handler: self._expected_notify_handler[1].cancel() def _monotonic_ms(self) -> int: """Return current monotonic time in milliseconds.""" return int(self._loop.time() * 1000.0) def current_timer_value(self) -> int: """Return current timer value in milliseconds.""" return self._monotonic_ms() + self._clock_difference def update(self, new_value: int) -> None: """Update timer value.""" self._clock_difference = new_value - self._monotonic_ms() def reschedule(self, update: tuple[bytes, bytes] | None = None) -> None: """ Reschedule notify timer. If `update` is set, the timer is rescheduled for an update notify, else for a periodic notify. `update` is expected to be a tuple of (message_tag, serial_number). """ if self._notify_timer_handle is not None: self._notify_timer_handle.cancel() self.sched_update = bool(update) min_delay: float max_delay: float if self.sched_update: if self.timekeeper: min_delay = self.MIN_DELAY_TIME_KEEPER_UPDATE_NOTIFY max_delay = self.max_delay_time_keeper_update_notify else: min_delay = self.min_delay_time_follower_update_notify max_delay = self.max_delay_time_follower_update_notify elif self.timekeeper: min_delay = self.MIN_DELAY_TIME_KEEPER_PERIODIC_NOTIFY max_delay = self.max_delay_time_keeper_periodic_notify else: min_delay = self.min_delay_time_follower_periodic_notify max_delay = self.max_delay_time_follower_periodic_notify self._notify_timer_handle = self._loop.call_later( random.uniform(min_delay, max_delay), self._notify_timer_expired, update ) def _notify_timer_expired(self, update: tuple[bytes, bytes] | None) -> None: """Notify timer expired.""" if update: self.send_timer_notify(message_tag=update[0], serial_number=update[1]) else: self.send_timer_notify() if not self.timekeeper: self.timekeeper = True ip_secure_logger.debug("Becoming timekeeper") self.reschedule() def send_timer_notify( self, message_tag: bytes | None = None, serial_number: bytes = XKNX_SERIAL_NUMBER, ) -> None: """Send a TimerNotify frame.""" timer_value = self.current_timer_value() timer_bytes = timer_value.to_bytes(6, "big") _message_tag = message_tag or random.randbytes(2) b_0 = timer_bytes + serial_number + _message_tag + b"\x00\x00" # TODO: get header data and total_length from TimerNotify class # or handle mac calculation in TimerNotify class directly mac_cbc = calculate_message_authentication_code_cbc( key=self._backbone_key, additional_data=self.TIMER_NOTIFY_HEADER, block_0=b_0, ) c_0 = timer_bytes + serial_number + _message_tag + b"\xff\x00" _, mac = encrypt_data_ctr( key=self._backbone_key, counter_0=c_0, mac_cbc=mac_cbc, ) self._transport_send( KNXIPFrame.init_from_body( TimerNotify( timer_value=timer_value, serial_number=serial_number, message_tag=_message_tag, message_authentication_code=mac, ) ), None, ) def verify_timer_notify_mac(self, timer_notify: TimerNotify) -> None: """Verify MAC of timer notify.""" timer_bytes = timer_notify.timer_value.to_bytes(6, "big") b_0 = ( timer_bytes + timer_notify.serial_number + timer_notify.message_tag + b"\x00\x00" ) c_0 = ( timer_bytes + timer_notify.serial_number + timer_notify.message_tag + b"\xff\x00" ) _, mac_tr = decrypt_ctr( key=self._backbone_key, counter_0=c_0, mac=timer_notify.message_authentication_code, ) mac_cbc = calculate_message_authentication_code_cbc( key=self._backbone_key, additional_data=self.TIMER_NOTIFY_HEADER, block_0=b_0, ) if mac_cbc != mac_tr: raise KNXSecureValidationError("MAC verification failed") async def synchronize(self) -> None: """Synchronize timer with remote time keeper.""" message_tag = random.randbytes(2) waiter_fut: asyncio.Future[int] = self._loop.create_future() self._expected_notify_handler = message_tag, waiter_fut self.send_timer_notify(message_tag=message_tag) try: async with asyncio_timeout( # 3.3 seconds at latency_ms=1000, SYNC_LATENCY_FRACTION=10% self.max_delay_time_follower_update_notify + 2 * self.latency_tolerance_ms / 1000 ): timer_value = await waiter_fut self.update(new_value=timer_value) except asyncio.TimeoutError: # use highest received timer value of TimerNotify or SecureWrapper frames ip_secure_logger.warning( "Timer synchronization not answered. Becoming time keeper." ) self.timekeeper = True except asyncio.CancelledError: return finally: self._expected_notify_handler = None self.timer_authenticated = True self.reschedule() def validate_secure_wrapper(self, secure_wrapper: SecureWrapper) -> bool: """Validate a SecureWrapper frames and handle timer update schedule.""" local_timer_value = self.current_timer_value() received_timer_value = int.from_bytes( secure_wrapper.sequence_information, "big" ) # §2.2.2.3.2.5 Events: E5 - E8 if received_timer_value > local_timer_value: self._clock_difference += received_timer_value - local_timer_value if not self.sched_update: self.reschedule() return True if received_timer_value > local_timer_value - self.sync_latency_tolerance_ms: if not self.sched_update: self.reschedule() return True if received_timer_value > local_timer_value - self.latency_tolerance_ms: return True if not self.sched_update: self.reschedule( update=(secure_wrapper.message_tag, secure_wrapper.serial_number) ) return False def handle_timer_notify(self, timer_notify: TimerNotify) -> None: """Handle received TimerNotify frame.""" local_timer_value = self.current_timer_value() try: self.verify_timer_notify_mac(timer_notify) except KNXSecureValidationError: ip_secure_logger.warning( "Discarding TimerNotify with invalid MAC: %s", timer_notify ) return received_timer_value = timer_notify.timer_value # §2.2.2.3.2.5 Events: E11 if ( self._expected_notify_handler is not None and timer_notify.serial_number == XKNX_SERIAL_NUMBER and timer_notify.message_tag == self._expected_notify_handler[0] ): fut = self._expected_notify_handler[1] fut.set_result(received_timer_value) return # §2.2.2.3.2.5 Events: E1 - E4 if received_timer_value > local_timer_value: self._clock_difference += received_timer_value - local_timer_value if self.timekeeper: ip_secure_logger.debug("Becoming time follower") self.timekeeper = False self.reschedule() return if received_timer_value > local_timer_value - self.sync_latency_tolerance_ms: if self.timekeeper: ip_secure_logger.debug("Becoming time follower") self.timekeeper = False self.reschedule() return if received_timer_value > local_timer_value - self.latency_tolerance_ms: return if not self.sched_update: self.reschedule( update=(timer_notify.message_tag, timer_notify.serial_number) ) def get_for_outgoing_secure_wrapper(self) -> int: """Return current timer value and handle timer update schedule.""" # §2.2.2.3.2.5 Events: E9 if not self.sched_update: self.reschedule() return self.current_timer_value()