diff --git a/README.adoc b/README.adoc index 8855f6b8..560ab95b 100644 --- a/README.adoc +++ b/README.adoc @@ -66,6 +66,22 @@ requires running as Administrator. This library can still be used when running as non-administrator, via the `fido.client.WindowsClient` class. An example of this is included in the file `examples/credential.py`. +To install the dependencies required for using IPC for accessing raw CTAP +functionality without elevation on Windows: + + pip install fido2[win] + +To enable the IPC service elevation is required, though once started, other programs +using the IPC may run without elevation. The following enables the service with auto +start on boot. + + python -m fido2.ipcservice.service --startup auto install + +To start the service once installed use the following: + + sc start CTAPIPCService + +For stopping and deleting the service, use corresponding sc commands. Under Linux you will need to add a Udev rule to be able to access the FIDO device, or run as root. For example, the Udev rule may contain the following: @@ -94,6 +110,9 @@ NFC support is optionally available via PC/SC, using the pyscard library. For instructions on installing this dependency, see https://github.com/LudovicRousseau/pyscard/blob/master/INSTALL.md. +Windows IPC support is optionally available via a Windows service using the pywin32 +library. For instructions on installing this dependency, see +https://github.com/mhammond/pywin32#installing-via-pip. === Development For development of the library we use https://docs.astral.sh/uv/[uv]. diff --git a/examples/exampleutils.py b/examples/exampleutils.py index 78ed52f0..ca110ab9 100644 --- a/examples/exampleutils.py +++ b/examples/exampleutils.py @@ -33,7 +33,7 @@ from getpass import getpass from fido2.client import DefaultClientDataCollector, Fido2Client, UserInteraction -from fido2.hid import CtapHidDevice +from fido2.hid import CtapHidDevice, ipc_available # Support NFC devices if we can try: @@ -46,7 +46,7 @@ from fido2.client.windows import WindowsClient use_winclient = ( - WindowsClient.is_available() and not ctypes.windll.shell32.IsUserAnAdmin() + WindowsClient.is_available() and not (ctypes.windll.shell32.IsUserAnAdmin() or ipc_available()) ) except Exception: use_winclient = False diff --git a/fido2/hid/__init__.py b/fido2/hid/__init__.py index faa79df0..a8bcab64 100644 --- a/fido2/hid/__init__.py +++ b/fido2/hid/__init__.py @@ -31,6 +31,7 @@ import os import struct import sys +import ctypes from enum import IntEnum, IntFlag, unique from threading import Event from typing import Callable, Iterator @@ -45,7 +46,11 @@ if sys.platform == "linux": from . import linux as backend elif sys.platform == "win32": - from . import windows as backend + from . import ipc as ipc + if not ctypes.windll.shell32.IsUserAnAdmin() and ipc.IPC_Pipe.is_pipe_available(): + from . import ipc as backend + else: + from . import windows as backend elif sys.platform == "darwin": from . import macos as backend # The following have version numbers at the end @@ -63,6 +68,13 @@ get_descriptor = backend.get_descriptor open_connection = backend.open_connection +def ipc_available() -> bool: + if sys.platform != "win32": + return False + if hasattr(backend, "IPC_Pipe"): + return backend.IPC_Pipe.is_pipe_available() + return False + class ConnectionFailure(Exception): """The CTAP connection failed or returned an invalid response.""" diff --git a/fido2/hid/ipc.py b/fido2/hid/ipc.py new file mode 100644 index 00000000..d46fb8a6 --- /dev/null +++ b/fido2/hid/ipc.py @@ -0,0 +1,178 @@ +import win32file +import win32pipe +import pywintypes +import random +import string +import json +import os +from fido2.hid.base import CtapHidConnection, HidDescriptor +from fido2.hid.ipc_util import Commands, InputOutput, Connections, DescriptorConvertor, PIPE_NAME +import time +import win32security +import ntsecuritycon as ntsec +import win32pipe +import win32con + +class IPC_Pipe: + def __init__(self, retry_time_ms=2000, retry_count=3): + for _ in range(retry_count): + try: + self.handle = win32file.CreateFile( + PIPE_NAME, + win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + 0, + None + ) + break + except pywintypes.error as e: + if e.winerror == 231: #Busy handles + time.sleep(retry_time_ms/1000) + + + @staticmethod + def is_pipe_available(timeout_ms=2000) -> bool: + try: + win32pipe.WaitNamedPipe(PIPE_NAME, timeout_ms) + handle = win32file.CreateFile( + PIPE_NAME, + win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + 0, + None + ) + random_data= os.urandom(16) + command = InputOutput( + command = Commands.ECHO, + data = random_data + ) + win32file.WriteFile(handle, command.to_binary()) + hr, response = win32file.ReadFile(handle, 65536) + response_data = InputOutput.from_binary(response).data + win32file.CloseHandle(handle) + assert response_data == b"echo"+random_data + return True + except (pywintypes.error, AssertionError): + return False + + def call_pipe(self, command: InputOutput, has_response: bool = True) -> InputOutput | None: + data = command.to_binary() + win32file.WriteFile(self.handle, data) + if has_response: + hr, response = win32file.ReadFile(self.handle, 65536) + return InputOutput.from_binary(response) + + def close_pipe(self): + win32file.CloseHandle(self.handle) + + +class IPC_Client: + def __init__(self): + self.pipe = IPC_Pipe() + + def list_descriptors(self): + command = InputOutput( + command = Commands.LIST_DESCRIPTORS + ) + resp = self.pipe.call_pipe(command) + data = resp.data + assert resp.command == Commands.LIST_DESCRIPTORS + descriptors_list = json.loads(data.decode()) + descriptors_list_hid = [DescriptorConvertor.str_to_descriptor(descriptor) for descriptor in descriptors_list] + return descriptors_list_hid + + def get_descriptor(self, path: bytes): + command = InputOutput( + command = Commands.GET_DESCRIPTOR, + data = path + ) + resp = self.pipe.call_pipe(command) + assert resp.command == Commands.GET_DESCRIPTOR + descriptor = DescriptorConvertor.str_to_descriptor(resp.data.decode()) + return descriptor + + def open_connection(self, descriptor: HidDescriptor): + command = InputOutput( + command = Commands.OPEN_CONNECTION, + data = DescriptorConvertor.descriptor_to_str(descriptor).encode() + ) + resp = self.pipe.call_pipe(command) + assert resp.command == Commands.OPEN_CONNECTION + handle = resp.data + return handle + + def read_packet(self, handle: bytes): + command = InputOutput( + command = Commands.READ_PACKET, + handle = handle + ) + resp = self.pipe.call_pipe(command) + assert resp.command == Commands.READ_PACKET + data = resp.data + return data + + def write_packet(self, handle: bytes, data: bytes): + command = InputOutput( + command = Commands.WRITE_PACKET, + handle = handle, + data = data + ) + + self.pipe.call_pipe(command, has_response = False) + + def close(self, handle: bytes): + command = InputOutput( + command = Commands.CLOSE, + handle = handle + ) + self.pipe.call_pipe(command, has_response = False) + +class ClientHandler: + def __init__(self): + self.ipc_client = None + + def open_client(self): + if self.ipc_client: + return + assert IPC_Pipe.is_pipe_available() + self.ipc_client = IPC_Client() + + def get_client(self): + self.open_client() + return self.ipc_client + + def close_client(self): + assert self.ipc_client + self.ipc_client.pipe.close_pipe() + +client_handler = ClientHandler() + +def list_descriptors(): + client = client_handler.get_client() + return client.list_descriptors() + +def get_descriptor(path): + client = client_handler.get_client() + return client.get_descriptor(path) + +class IPC_Connection(CtapHidConnection): + def __init__(self, client: IPC_Client, descriptor: HidDescriptor): + self.client = client + self.handle = self.client.open_connection(descriptor) + + def read_packet(self): + return self.client.read_packet(self.handle) + + def write_packet(self, data): + self.client.write_packet(self.handle, data) + + def close(self): + self.client.close(self.handle) + +def open_connection(descriptor): + client = client_handler.get_client() + return IPC_Connection(client, descriptor) diff --git a/fido2/hid/ipc_util.py b/fido2/hid/ipc_util.py new file mode 100644 index 00000000..5139be10 --- /dev/null +++ b/fido2/hid/ipc_util.py @@ -0,0 +1,85 @@ +import struct +import json +import os +from enum import Enum +from dataclasses import dataclass, asdict, field +from fido2.hid.base import FIDO_USAGE, FIDO_USAGE_PAGE, CtapHidConnection, HidDescriptor + +HANDLE_SIZE = 16 +PIPE_NAME = r"\\.\pipe\CTAPPipe" + +class Commands(int, Enum): + LIST_DESCRIPTORS = 0x00 + GET_DESCRIPTOR = 0x01 + OPEN_CONNECTION = 0x02 + READ_PACKET = 0x03 + WRITE_PACKET = 0x04 + CLOSE = 0x05 + ERROR = 0x06 + ECHO = 0x07 + +@dataclass +class InputOutput: + command: Commands + handle: bytes = b"" + data: bytes = b"" + + @staticmethod + def from_binary(bindata: bytes): + command = Commands(bindata[0]) + remaining = bindata[1:] + handle = b'' + if command in (Commands.READ_PACKET, Commands.WRITE_PACKET, Commands.CLOSE): + handle = remaining[:HANDLE_SIZE] + remaining = remaining[HANDLE_SIZE:] + data = remaining + return InputOutput( + command = command, + handle = handle, + data = data + ) + + def to_binary(self): + if self.command in (Commands.READ_PACKET, Commands.WRITE_PACKET, Commands.CLOSE): + return struct.pack( + f"B{HANDLE_SIZE}s{len(self.data)}s", + self.command, + self.handle, + self.data, + ) + else: + return struct.pack( + f"B{len(self.data)}s", + self.command, + self.data, + ) + +@dataclass +class Connections(): + connections: dict[bytes, CtapHidConnection] = field(default_factory=dict) + + def add_connection(self, connection: CtapHidConnection) -> bytes: + handle = os.urandom(HANDLE_SIZE) + self.connections[handle] = connection + return handle + + def remove_connection(self, handle: bytes) -> CtapHidConnection: + return self.connections.pop(handle) + + def get_connection(self, handle: bytes) -> CtapHidConnection: + return self.connections.get(handle) + +class DescriptorConvertor: + + @staticmethod + def descriptor_to_str(descriptor: HidDescriptor) -> str: + descriptor_dict = asdict(descriptor) + if isinstance(descriptor_dict.get("path", None), bytes): + descriptor_dict["path"] = descriptor_dict["path"].decode() + return json.dumps(descriptor_dict) + + @staticmethod + def str_to_descriptor(descriptor_str: str) -> HidDescriptor: + descriptor_dict = json.loads(descriptor_str) + descriptor_dict["path"] = descriptor_dict["path"].encode() + return HidDescriptor(**descriptor_dict) diff --git a/fido2/ipcservice/service.py b/fido2/ipcservice/service.py new file mode 100644 index 00000000..5320cabd --- /dev/null +++ b/fido2/ipcservice/service.py @@ -0,0 +1,256 @@ +import win32serviceutil +import win32service +import win32event +import win32pipe +import win32file +import win32security +import win32con +import pywintypes +import winerror + +import servicemanager +import logging +import json +import threading +from dataclasses import asdict +from fido2.hid import windows +from fido2.hid.base import CtapHidConnection, HidDescriptor +from fido2.hid.ipc_util import Commands, InputOutput, Connections, DescriptorConvertor, PIPE_NAME + +log = logging.getLogger(__name__) + +class CTAP_Broker: + def __init__(self): + self.connections = Connections() + + def list_descriptors(self) -> bytes: + descriptors_list = windows.list_descriptors() + descriptors_list_str = [DescriptorConvertor.descriptor_to_str(descriptor) for descriptor in descriptors_list] + return json.dumps(descriptors_list_str).encode() + + def get_descriptor(self, path: bytes) -> bytes: + descriptor = windows.get_descriptor(path) + return DescriptorConvertor.descriptor_to_str(descriptor).encode() + + def open_connection(self, descriptor: bytes) -> bytes: + descriptor_hid = DescriptorConvertor.str_to_descriptor(descriptor.decode()) + connection = windows.open_connection(descriptor_hid) + handle = self.connections.add_connection(connection) + return handle + + def read_packet(self, handle: bytes) -> bytes: + connection = self.connections.get_connection(handle) + return connection.read_packet() + + def write_packet(self, handle: bytes, data: bytes) -> None: + connection = self.connections.get_connection(handle) + connection.write_packet(data) + + def close(self, handle: bytes) -> None: + connection = self.connections.remove_connection(handle) + connection.close() + + def echo(self, data: bytes) -> bytes: + return b"echo"+data + + + + def process(self, data: bytes) -> bytes: + try: + io_command = InputOutput.from_binary(data) + if io_command.command == Commands.LIST_DESCRIPTORS: + resp = InputOutput( + command = io_command.command, + data = self.list_descriptors() + ) + return resp.to_binary() + + if io_command.command == Commands.GET_DESCRIPTOR: + resp = InputOutput( + command = io_command.command, + data = self.get_descriptor(io_command.data) + ) + return resp.to_binary() + + if io_command.command == Commands.OPEN_CONNECTION: + resp = InputOutput( + command = io_command.command, + data = self.open_connection(io_command.data) + ) + return resp.to_binary() + + if io_command.command == Commands.READ_PACKET: + resp = InputOutput( + command = io_command.command, + data = self.read_packet(io_command.handle) + ) + return resp.to_binary() + + if io_command.command == Commands.WRITE_PACKET: + self.write_packet(io_command.handle, io_command.data) + return b"" + + if io_command.command == Commands.CLOSE: + self.close(io_command.handle) + return b"" + + if io_command.command == Commands.ECHO: + resp = InputOutput( + command = io_command.command, + data = self.echo(io_command.data) + ) + return resp.to_binary() + + except Exception as e: + log.exception(f"Exception in processing command {e}") + resp = InputOutput( + command = Commands.ERROR + ) + return resp.to_binary() + + + +class CTAPIPCService(win32serviceutil.ServiceFramework): + _svc_name_ = "CTAPIPCService" + _svc_display_name_ = "CTAP IPC Service" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.stop_event = win32event.CreateEvent(None, 0, 0, None) + self.running = True + self.ctap_broker = CTAP_Broker() + self.current_pipe = None + + @classmethod + def SvcInstall(cls): + win32serviceutil.ServiceFramework.SvcInstall(cls) + cls._set_recovery() + + @classmethod + def _set_recovery(cls, restart_delay_ms=3000, reset_period_sec=86400): + hscm = win32service.OpenSCManager(None, None, win32service.SC_MANAGER_ALL_ACCESS) + try: + hs = win32service.OpenService(hscm, cls._svc_name_, win32service.SERVICE_ALL_ACCESS) + try: + actions = [(win32service.SERVICE_RESTART, restart_delay_ms)] + failure_actions = { + 'ResetPeriod': reset_period_sec, + 'RebootMsg': '', + 'Command': '', + 'Actions': actions + } + win32service.ChangeServiceConfig2( + hs, win32service.SERVICE_CONFIG_FAILURE_ACTIONS, failure_actions + ) + win32service.ChangeServiceConfig2( + hs, win32service.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, True + ) + log.info("Recovery actions configured") + finally: + win32service.CloseServiceHandle(hs) + finally: + win32service.CloseServiceHandle(hscm) + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + self.running = False + win32event.SetEvent(self.stop_event) + if self.current_pipe is not None: + try: + win32file.CancelIoEx(self.current_pipe) + except Exception: + pass + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, '') + ) + try: + self.main() + except Exception: + log.exception("Fatal error — service exiting") + # Raise will make it exit with non zero exit code and will restart + raise + + def make_pipe_security_attributes(self): + sd = win32security.SECURITY_DESCRIPTOR() + dacl = win32security.ACL() + authenticated_users_sid = win32security.ConvertStringSidToSid("S-1-5-11") + dacl.AddAccessAllowedAce( + win32security.ACL_REVISION, + win32con.GENERIC_READ | win32con.GENERIC_WRITE, + authenticated_users_sid + ) + sd.SetSecurityDescriptorDacl(1, dacl, 0) + sa = win32security.SECURITY_ATTRIBUTES() + sa.SECURITY_DESCRIPTOR = sd + return sa + + def main(self): + pipe = win32pipe.CreateNamedPipe( + PIPE_NAME, + win32pipe.PIPE_ACCESS_DUPLEX | win32file.FILE_FLAG_OVERLAPPED, + win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT, + win32pipe.PIPE_UNLIMITED_INSTANCES, + 65536, 65536, + 0, + self.make_pipe_security_attributes() + ) + self.current_pipe = pipe + + try: + while self.running: + overlapped = pywintypes.OVERLAPPED() + overlapped.hEvent = win32event.CreateEvent(None, 0, 0, None) + + log.info("Waiting for client connection...") + try: + win32pipe.ConnectNamedPipe(pipe, overlapped) + except pywintypes.error as e: + if e.winerror == winerror.ERROR_PIPE_CONNECTED: + win32event.SetEvent(overlapped.hEvent) + if e.winerror != winerror.ERROR_IO_PENDING: + raise + + wait_result = win32event.WaitForMultipleObjects( + [self.stop_event, overlapped.hEvent], False, win32event.INFINITE + ) + + if wait_result == win32event.WAIT_OBJECT_0: + log.info("Stop requested, exiting") + return + + log.info("Client connected") + self.handle_client(pipe) + + win32pipe.DisconnectNamedPipe(pipe) + finally: + try: + win32file.CloseHandle(pipe) + except Exception: + pass + self.current_pipe = None + + def handle_client(self, pipe): + try: + while self.running: + hr, data = win32file.ReadFile(pipe, 65536) + if not data: + break + result = self.ctap_broker.process(data) + if result: + win32file.WriteFile(pipe, result) + except pywintypes.error as e: + if e.winerror == 109: + log.info("Client disconnected") + else: + log.exception("Unexpected pipe error") + except Exception: + log.exception("Error handling client") + + + +if __name__ == '__main__': + win32serviceutil.HandleCommandLine(CTAPIPCService) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 33356007..30d5e6bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = ["cryptography (>=2.6, !=35, <52)"] [project.optional-dependencies] pcsc = ["pyscard (>=1.9, <3)"] +win = ["pywin32 (>=311) ; sys_platform == 'win32'"] [dependency-groups] dev = [