1# Copyright 2016 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#    http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Implements HID device interface on MacOS using IOKit and HIDManager."""
16from six.moves import queue
17from six.moves import range
18import ctypes
19import ctypes.util
20import logging
21import sys
22import threading
23
24from pyu2f import errors
25from pyu2f.hid import base
26
27logger = logging.getLogger('pyu2f.macos')
28
29# Constants
30DEVICE_PATH_BUFFER_SIZE = 512
31DEVICE_STRING_PROPERTY_BUFFER_SIZE = 512
32
33HID_DEVICE_PROPERTY_VENDOR_ID = 'VendorId'
34HID_DEVICE_PROPERTY_PRODUCT_ID = 'ProductID'
35HID_DEVICE_PROPERTY_PRODUCT = 'Product'
36HID_DEVICE_PROPERTY_PRIMARY_USAGE = 'PrimaryUsage'
37HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE = 'PrimaryUsagePage'
38HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE = 'MaxInputReportSize'
39HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE = 'MaxOutputReportSize'
40HID_DEVICE_PROPERTY_REPORT_ID = 'ReportID'
41
42
43# Declare C types
44class _CFType(ctypes.Structure):
45  pass
46
47
48class _CFString(_CFType):
49  pass
50
51
52class _CFSet(_CFType):
53  pass
54
55
56class _IOHIDManager(_CFType):
57  pass
58
59
60class _IOHIDDevice(_CFType):
61  pass
62
63
64class _CFRunLoop(_CFType):
65  pass
66
67
68class _CFAllocator(_CFType):
69  pass
70
71
72# Linter isn't consistent about valid class names. Disabling some of the errors
73CF_SET_REF = ctypes.POINTER(_CFSet)
74CF_STRING_REF = ctypes.POINTER(_CFString)
75CF_TYPE_REF = ctypes.POINTER(_CFType)
76CF_RUN_LOOP_REF = ctypes.POINTER(_CFRunLoop)
77CF_RUN_LOOP_RUN_RESULT = ctypes.c_int32
78CF_ALLOCATOR_REF = ctypes.POINTER(_CFAllocator)
79CF_TYPE_ID = ctypes.c_ulong  # pylint: disable=invalid-name
80CF_INDEX = ctypes.c_long  # pylint: disable=invalid-name
81CF_TIME_INTERVAL = ctypes.c_double  # pylint: disable=invalid-name
82IO_RETURN = ctypes.c_uint
83IO_HID_REPORT_TYPE = ctypes.c_uint
84IO_OBJECT_T = ctypes.c_uint
85MACH_PORT_T = ctypes.c_uint
86IO_STRING_T = ctypes.c_char_p  # pylint: disable=invalid-name
87IO_SERVICE_T = IO_OBJECT_T
88IO_REGISTRY_ENTRY_T = IO_OBJECT_T
89
90IO_HID_MANAGER_REF = ctypes.POINTER(_IOHIDManager)
91IO_HID_DEVICE_REF = ctypes.POINTER(_IOHIDDevice)
92
93IO_HID_REPORT_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.py_object, IO_RETURN,
94                                          ctypes.c_void_p, IO_HID_REPORT_TYPE,
95                                          ctypes.c_uint32,
96                                          ctypes.POINTER(ctypes.c_uint8),
97                                          CF_INDEX)
98
99# Define C constants
100K_CF_NUMBER_SINT32_TYPE = 3
101K_CF_STRING_ENCODING_UTF8 = 0x08000100
102K_CF_ALLOCATOR_DEFAULT = None
103
104K_IO_SERVICE_PLANE = b'IOService'
105K_IO_MASTER_PORT_DEFAULT = 0
106K_IO_HID_REPORT_TYPE_OUTPUT = 1
107K_IO_RETURN_SUCCESS = 0
108
109K_CF_RUN_LOOP_RUN_STOPPED = 2
110K_CF_RUN_LOOP_RUN_TIMED_OUT = 3
111K_CF_RUN_LOOP_RUN_HANDLED_SOURCE = 4
112
113# Load relevant libraries
114iokit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('IOKit'))
115cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library('CoreFoundation'))
116
117# Only use iokit and cf if we're on macos, this way we can still run tests
118# on other platforms if we properly mock
119if sys.platform.startswith('darwin'):
120  # Exported constants
121  K_CF_RUNLOOP_DEFAULT_MODE = CF_STRING_REF.in_dll(cf, 'kCFRunLoopDefaultMode')
122
123  # Declare C function prototypes
124  cf.CFSetGetValues.restype = None
125  cf.CFSetGetValues.argtypes = [CF_SET_REF, ctypes.POINTER(ctypes.c_void_p)]
126  cf.CFStringCreateWithCString.restype = CF_STRING_REF
127  cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p,
128                                           ctypes.c_uint32]
129  cf.CFStringGetCString.restype = ctypes.c_int
130  cf.CFStringGetCString.argtypes = [CF_STRING_REF, ctypes.c_char_p, CF_INDEX,
131                                    ctypes.c_uint32]
132  cf.CFStringGetLength.restype = CF_INDEX
133  cf.CFStringGetLength.argtypes = [CF_STRING_REF]
134  cf.CFGetTypeID.restype = CF_TYPE_ID
135  cf.CFGetTypeID.argtypes = [CF_TYPE_REF]
136  cf.CFNumberGetTypeID.restype = CF_TYPE_ID
137  cf.CFNumberGetValue.restype = ctypes.c_int
138  cf.CFRelease.restype = None
139  cf.CFRelease.argtypes = [CF_TYPE_REF]
140  cf.CFRunLoopGetCurrent.restype = CF_RUN_LOOP_REF
141  cf.CFRunLoopGetCurrent.argTypes = []
142  cf.CFRunLoopRunInMode.restype = CF_RUN_LOOP_RUN_RESULT
143  cf.CFRunLoopRunInMode.argtypes = [CF_STRING_REF, CF_TIME_INTERVAL,
144                                    ctypes.c_bool]
145
146  iokit.IOObjectRelease.argtypes = [IO_OBJECT_T]
147  iokit.IOHIDManagerCreate.restype = IO_HID_MANAGER_REF
148  iokit.IOHIDManagerCopyDevices.restype = CF_SET_REF
149  iokit.IOHIDManagerCopyDevices.argtypes = [IO_HID_MANAGER_REF]
150  iokit.IOHIDManagerSetDeviceMatching.restype = None
151  iokit.IOHIDManagerSetDeviceMatching.argtypes = [IO_HID_MANAGER_REF,
152                                                  CF_TYPE_REF]
153  iokit.IOHIDDeviceGetProperty.restype = CF_TYPE_REF
154  iokit.IOHIDDeviceGetProperty.argtypes = [IO_HID_DEVICE_REF, CF_STRING_REF]
155  iokit.IOHIDDeviceRegisterInputReportCallback.restype = None
156  iokit.IOHIDDeviceRegisterInputReportCallback.argtypes = [
157      IO_HID_DEVICE_REF, ctypes.POINTER(ctypes.c_uint8), CF_INDEX,
158      IO_HID_REPORT_CALLBACK, ctypes.py_object]
159  iokit.IORegistryEntryFromPath.restype = IO_REGISTRY_ENTRY_T
160  iokit.IORegistryEntryFromPath.argtypes = [MACH_PORT_T, IO_STRING_T]
161  iokit.IOHIDDeviceCreate.restype = IO_HID_DEVICE_REF
162  iokit.IOHIDDeviceCreate.argtypes = [CF_ALLOCATOR_REF, IO_SERVICE_T]
163  iokit.IOHIDDeviceScheduleWithRunLoop.restype = None
164  iokit.IOHIDDeviceScheduleWithRunLoop.argtypes = [IO_HID_DEVICE_REF,
165                                                   CF_RUN_LOOP_REF,
166                                                   CF_STRING_REF]
167  iokit.IOHIDDeviceUnscheduleFromRunLoop.restype = None
168  iokit.IOHIDDeviceUnscheduleFromRunLoop.argtypes = [IO_HID_DEVICE_REF,
169                                                     CF_RUN_LOOP_REF,
170                                                     CF_STRING_REF]
171  iokit.IOHIDDeviceSetReport.restype = IO_RETURN
172  iokit.IOHIDDeviceSetReport.argtypes = [IO_HID_DEVICE_REF, IO_HID_REPORT_TYPE,
173                                         CF_INDEX,
174                                         ctypes.POINTER(ctypes.c_uint8),
175                                         CF_INDEX]
176else:
177  logger.warn('Not running on MacOS')
178
179
180def CFStr(s):
181  """Builds a CFString from a python string.
182
183  Args:
184    s: source string
185
186  Returns:
187    CFStringRef representation of the source string
188
189  Resulting CFString must be CFReleased when no longer needed.
190  """
191  return cf.CFStringCreateWithCString(None, s.encode(), 0)
192
193
194def GetDeviceIntProperty(dev_ref, key):
195  """Reads int property from the HID device."""
196  cf_key = CFStr(key)
197  type_ref = iokit.IOHIDDeviceGetProperty(dev_ref, cf_key)
198  cf.CFRelease(cf_key)
199  if not type_ref:
200    return None
201
202  if cf.CFGetTypeID(type_ref) != cf.CFNumberGetTypeID():
203    raise errors.OsHidError('Expected number type, got {}'.format(
204        cf.CFGetTypeID(type_ref)))
205
206  out = ctypes.c_int32()
207  ret = cf.CFNumberGetValue(type_ref, K_CF_NUMBER_SINT32_TYPE,
208                            ctypes.byref(out))
209  if not ret:
210    return None
211
212  return out.value
213
214
215def GetDeviceStringProperty(dev_ref, key):
216  """Reads string property from the HID device."""
217  cf_key = CFStr(key)
218  type_ref = iokit.IOHIDDeviceGetProperty(dev_ref, cf_key)
219  cf.CFRelease(cf_key)
220  if not type_ref:
221    return None
222
223  if cf.CFGetTypeID(type_ref) != cf.CFStringGetTypeID():
224    raise errors.OsHidError('Expected string type, got {}'.format(
225        cf.CFGetTypeID(type_ref)))
226
227  type_ref = ctypes.cast(type_ref, CF_STRING_REF)
228  out = ctypes.create_string_buffer(DEVICE_STRING_PROPERTY_BUFFER_SIZE)
229  ret = cf.CFStringGetCString(type_ref, out, DEVICE_STRING_PROPERTY_BUFFER_SIZE,
230                              K_CF_STRING_ENCODING_UTF8)
231  if not ret:
232    return None
233
234  return out.value.decode('utf8')
235
236
237def GetDevicePath(device_handle):
238  """Obtains the unique path for the device.
239
240  Args:
241    device_handle: reference to the device
242
243  Returns:
244    A unique path for the device, obtained from the IO Registry
245
246  """
247  # Obtain device path from IO Registry
248  io_service_obj = iokit.IOHIDDeviceGetService(device_handle)
249  str_buffer = ctypes.create_string_buffer(DEVICE_PATH_BUFFER_SIZE)
250  iokit.IORegistryEntryGetPath(io_service_obj, K_IO_SERVICE_PLANE, str_buffer)
251
252  return str_buffer.value
253
254
255def HidReadCallback(read_queue, result, sender, report_type, report_id, report,
256                    report_length):
257  """Handles incoming IN report from HID device."""
258  del result, sender, report_type, report_id  # Unused by the callback function
259
260  incoming_bytes = [report[i] for i in range(report_length)]
261  read_queue.put(incoming_bytes)
262
263# C wrapper around ReadCallback()
264# Declared in this scope so it doesn't get GC-ed
265REGISTERED_READ_CALLBACK = IO_HID_REPORT_CALLBACK(HidReadCallback)
266
267
268def DeviceReadThread(hid_device):
269  """Binds a device to the thread's run loop, then starts the run loop.
270
271  Args:
272    hid_device: The MacOsHidDevice object
273
274  The HID manager requires a run loop to handle Report reads. This thread
275  function serves that purpose.
276  """
277
278  # Schedule device events with run loop
279  hid_device.run_loop_ref = cf.CFRunLoopGetCurrent()
280  if not hid_device.run_loop_ref:
281    logger.error('Failed to get current run loop')
282    return
283
284  iokit.IOHIDDeviceScheduleWithRunLoop(hid_device.device_handle,
285                                       hid_device.run_loop_ref,
286                                       K_CF_RUNLOOP_DEFAULT_MODE)
287
288  # Run the run loop
289  run_loop_run_result = K_CF_RUN_LOOP_RUN_TIMED_OUT
290  while (run_loop_run_result == K_CF_RUN_LOOP_RUN_TIMED_OUT or
291         run_loop_run_result == K_CF_RUN_LOOP_RUN_HANDLED_SOURCE):
292    run_loop_run_result = cf.CFRunLoopRunInMode(
293        K_CF_RUNLOOP_DEFAULT_MODE,
294        1000,  # Timeout in seconds
295        False)  # Return after source handled
296
297  # log any unexpected run loop exit
298  if run_loop_run_result != K_CF_RUN_LOOP_RUN_STOPPED:
299    logger.error('Unexpected run loop exit code: %d', run_loop_run_result)
300
301  # Unschedule from run loop
302  iokit.IOHIDDeviceUnscheduleFromRunLoop(hid_device.device_handle,
303                                         hid_device.run_loop_ref,
304                                         K_CF_RUNLOOP_DEFAULT_MODE)
305
306
307class MacOsHidDevice(base.HidDevice):
308  """Implementation of HID device for MacOS.
309
310  Uses IOKit HID Manager to interact with the device.
311  """
312
313  @staticmethod
314  def Enumerate():
315    """See base class."""
316    # Init a HID manager
317    hid_mgr = iokit.IOHIDManagerCreate(None, None)
318    if not hid_mgr:
319      raise errors.OsHidError('Unable to obtain HID manager reference')
320    iokit.IOHIDManagerSetDeviceMatching(hid_mgr, None)
321
322    # Get devices from HID manager
323    device_set_ref = iokit.IOHIDManagerCopyDevices(hid_mgr)
324    if not device_set_ref:
325      raise errors.OsHidError('Failed to obtain devices from HID manager')
326
327    num = iokit.CFSetGetCount(device_set_ref)
328    devices = (IO_HID_DEVICE_REF * num)()
329    iokit.CFSetGetValues(device_set_ref, devices)
330
331    # Retrieve and build descriptor dictionaries for each device
332    descriptors = []
333    for dev in devices:
334      d = base.DeviceDescriptor()
335      d.vendor_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_VENDOR_ID)
336      d.product_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_PRODUCT_ID)
337      d.product_string = GetDeviceStringProperty(dev,
338                                                 HID_DEVICE_PROPERTY_PRODUCT)
339      d.usage = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_PRIMARY_USAGE)
340      d.usage_page = GetDeviceIntProperty(
341          dev, HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE)
342      d.report_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_REPORT_ID)
343      d.path = GetDevicePath(dev)
344      descriptors.append(d.ToPublicDict())
345
346    # Clean up CF objects
347    cf.CFRelease(device_set_ref)
348    cf.CFRelease(hid_mgr)
349
350    return descriptors
351
352  def __init__(self, path):
353    # Resolve the path to device handle
354    device_entry = iokit.IORegistryEntryFromPath(K_IO_MASTER_PORT_DEFAULT, path)
355    if not device_entry:
356      raise errors.OsHidError('Device path does not match any HID device on '
357                              'the system')
358
359    self.device_handle = iokit.IOHIDDeviceCreate(K_CF_ALLOCATOR_DEFAULT,
360                                                 device_entry)
361    if not self.device_handle:
362      raise errors.OsHidError('Failed to obtain device handle from registry '
363                              'entry')
364    iokit.IOObjectRelease(device_entry)
365
366    self.device_path = path
367
368    # Open device
369    result = iokit.IOHIDDeviceOpen(self.device_handle, 0)
370    if result != K_IO_RETURN_SUCCESS:
371      raise errors.OsHidError('Failed to open device for communication: {}'
372                              .format(result))
373
374    # Create read queue
375    self.read_queue = queue.Queue()
376
377    # Create and start read thread
378    self.run_loop_ref = None
379    self.read_thread = threading.Thread(target=DeviceReadThread,
380                                        args=(self,))
381    self.read_thread.daemon = True
382    self.read_thread.start()
383
384    # Read max report sizes for in/out
385    self.internal_max_in_report_len = GetDeviceIntProperty(
386        self.device_handle,
387        HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE)
388    if not self.internal_max_in_report_len:
389      raise errors.OsHidError('Unable to obtain max in report size')
390
391    self.internal_max_out_report_len = GetDeviceIntProperty(
392        self.device_handle,
393        HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE)
394    if not self.internal_max_out_report_len:
395      raise errors.OsHidError('Unable to obtain max out report size')
396
397    # Register read callback
398    self.in_report_buffer = (ctypes.c_uint8 * self.internal_max_in_report_len)()
399    iokit.IOHIDDeviceRegisterInputReportCallback(
400        self.device_handle,
401        self.in_report_buffer,
402        self.internal_max_in_report_len,
403        REGISTERED_READ_CALLBACK,
404        ctypes.py_object(self.read_queue))
405
406  def GetInReportDataLength(self):
407    """See base class."""
408    return self.internal_max_in_report_len
409
410  def GetOutReportDataLength(self):
411    """See base class."""
412    return self.internal_max_out_report_len
413
414  def Write(self, packet):
415    """See base class."""
416    report_id = 0
417    out_report_buffer = (ctypes.c_uint8 * self.internal_max_out_report_len)()
418    out_report_buffer[:] = packet[:]
419
420    result = iokit.IOHIDDeviceSetReport(self.device_handle,
421                                        K_IO_HID_REPORT_TYPE_OUTPUT,
422                                        report_id,
423                                        out_report_buffer,
424                                        self.internal_max_out_report_len)
425
426    # Non-zero status indicates failure
427    if result != K_IO_RETURN_SUCCESS:
428      raise errors.OsHidError('Failed to write report to device')
429
430  def Read(self):
431    """See base class."""
432
433    result = None
434    while result is None:
435        try:
436            result = self.read_queue.get(timeout=60)
437        except queue.Empty:
438            continue
439
440    return result
441
442  def __del__(self):
443    # Unregister the callback
444    if hasattr(self, 'in_report_buffer'):
445        iokit.IOHIDDeviceRegisterInputReportCallback(
446            self.device_handle,
447            self.in_report_buffer,
448            self.internal_max_in_report_len,
449            None,
450            None)
451
452    # Stop the run loop
453    if hasattr(self, 'run_loop_ref'):
454        cf.CFRunLoopStop(self.run_loop_ref)
455
456    # Wait for the read thread to exit
457    if hasattr(self, 'read_thread'):
458        self.read_thread.join()
459