From 1588dc49f860203f4a59374996f9f4a40611cca1 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Thu, 6 Aug 2026 18:56:55 +0300 Subject: [PATCH 1/5] Tolerate sensors that do not ACK every command Every Device method assumes the sensor sends an ACK frame before the reply: if isinstance(self.protocol, protocol.USBProtocol): check_ack( check_message_protocol( check_message_pack(self.protocol.read()), COMMAND_ACK), COMMAND_FIRMWARE_VERSION) That assumption does not hold for every device. 27c6:533c (GF5288_GM168SEC, Dell XPS 13 9310) ACKs reset and read_sensor_register but answers firmware_version and enable_chip with the reply alone. The unconditional read then consumes that reply, fails to parse it as an ACK, and the command errors out on a perfectly healthy sensor: firmware_version() -> ValueError: Invalid message protocol enable_chip(True) -> USBTimeoutError: [Errno 110] Operation timed out Add Device._expect_ack(), which reads one frame and puts it back via Device.read() when it turns out not to be an ACK, so the caller reads it as the reply. Sensors that do ACK are unaffected -- the ACK is consumed and verified exactly as before. This is strictly more permissive, so nothing that worked before changes. It also collapses 31 five-line blocks into one-liners, for a net -88 lines. Verified on 27c6:533c, all six commands now succeeding where two used to fail: nop OK enable_chip OK (was: USBTimeoutError) firmware_version OK GF5288_GM168SEC_APP_13016 (was: ValueError) reset OK (True, 1024) read_sensor_register OK 0c a1 00 22 read_otp OK 68 e6 86 4a 54 ec 15 04 ... Co-Authored-By: Claude Opus 5 (1M context) --- goodix.py | 270 ++++++++++++++++++------------------------------------ 1 file changed, 91 insertions(+), 179 deletions(-) diff --git a/goodix.py b/goodix.py index b98009d..37c355e 100644 --- a/goodix.py +++ b/goodix.py @@ -152,18 +152,52 @@ def __init__(self, print(f"__init__({product}, {proto}, {timeout})") self.protocol: protocol.Protocol = proto(0x27c6, product, timeout) + self._pending: bytes | None = None # FIXME Empty device's reply buffer # (Current patch while waiting for a fix) if isinstance(self.protocol, protocol.USBProtocol): self.empty_buffer() + def read(self, size: int = 0x10000, timeout: float | None = 5): + """Read a frame, returning any frame put back by _expect_ack() first.""" + if self._pending is not None: + frame, self._pending = self._pending, None + return frame + + return self.protocol.read(size, timeout) + + def _expect_ack(self, command: int): + """Consume the ACK for `command`, if this sensor sends one. + + Not every sensor ACKs every command. 27c6:533c (GF5288_GM168SEC) ACKs + reset and read_sensor_register but answers firmware_version and + enable_chip with the reply alone. Requiring an ACK unconditionally + consumes that reply and then fails to parse it as an ACK, so those + commands error out on a perfectly healthy device. When the frame we + get is not an ACK, put it back for the caller to read. + """ + if not isinstance(self.protocol, protocol.USBProtocol): + return + + frame = self.read() + message = check_message_pack(frame) + payload, reply_command, _ = decode_message_protocol(message) + + if reply_command != COMMAND_ACK: + self._pending = frame + return + + check_ack(payload, command) + def empty_buffer(self): print("empty_buffer()") + self._pending = None + try: while True: - self.protocol.read(timeout=0.1) + self.read(timeout=0.1) except usb.core.USBTimeoutError as error: if error.backend_error_code == -7: @@ -186,7 +220,8 @@ def nop(self): checksum=False))) try: - message = self.protocol.read(timeout=0.1) + # some sensors do not answer NOP at all; a timeout here is fine + self._pending = self.read(timeout=0.1) except usb.core.USBTimeoutError as error: if error.backend_error_code == -7: @@ -194,10 +229,7 @@ def nop(self): raise error - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol(check_message_pack(message), - COMMAND_ACK), COMMAND_NOP) + self._expect_ack(COMMAND_NOP) def mcu_get_image(self, payload: bytes, flags: int): print("mcu_get_image()") @@ -206,13 +238,9 @@ def mcu_get_image(self, payload: bytes, flags: int): encode_message_pack( encode_message_protocol(payload, COMMAND_MCU_GET_IMAGE))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_MCU_GET_IMAGE) + self._expect_ack(COMMAND_MCU_GET_IMAGE) - return check_message_pack(self.protocol.read(), flags) + return check_message_pack(self.read(), flags) def mcu_switch_to_fdt_down(self, mode: bytes, reply: bool): print(f"mcu_switch_to_fdt_down({mode}, {reply})") @@ -221,17 +249,13 @@ def mcu_switch_to_fdt_down(self, mode: bytes, reply: bool): encode_message_pack( encode_message_protocol(mode, COMMAND_MCU_SWITCH_TO_FDT_DOWN))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_MCU_SWITCH_TO_FDT_DOWN) + self._expect_ack(COMMAND_MCU_SWITCH_TO_FDT_DOWN) if not reply: return None return check_message_protocol( - check_message_pack(self.protocol.read(timeout=None)), + check_message_pack(self.read(timeout=None)), COMMAND_MCU_SWITCH_TO_FDT_DOWN) def mcu_switch_to_fdt_up(self, mode: bytes): @@ -241,14 +265,10 @@ def mcu_switch_to_fdt_up(self, mode: bytes): encode_message_pack( encode_message_protocol(mode, COMMAND_MCU_SWITCH_TO_FDT_UP))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_MCU_SWITCH_TO_FDT_UP) + self._expect_ack(COMMAND_MCU_SWITCH_TO_FDT_UP) return check_message_protocol( - check_message_pack(self.protocol.read(timeout=None)), + check_message_pack(self.read(timeout=None)), COMMAND_MCU_SWITCH_TO_FDT_UP) def mcu_switch_to_fdt_mode(self, mode: bytes, reply: bool): @@ -258,16 +278,12 @@ def mcu_switch_to_fdt_mode(self, mode: bytes, reply: bool): encode_message_pack( encode_message_protocol(mode, COMMAND_MCU_SWITCH_TO_FDT_MODE))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_MCU_SWITCH_TO_FDT_MODE) + self._expect_ack(COMMAND_MCU_SWITCH_TO_FDT_MODE) if not reply: return None - return check_message_protocol(check_message_pack(self.protocol.read()), + return check_message_protocol(check_message_pack(self.read()), COMMAND_MCU_SWITCH_TO_FDT_MODE) def nav(self): @@ -277,13 +293,9 @@ def nav(self): encode_message_pack( encode_message_protocol(b"\x01\x00", COMMAND_NAV))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_NAV) + self._expect_ack(COMMAND_NAV) - return check_message_protocol(check_message_pack(self.protocol.read()), + return check_message_protocol(check_message_pack(self.read()), COMMAND_NAV, False) def mcu_switch_to_sleep_mode(self): @@ -294,11 +306,7 @@ def mcu_switch_to_sleep_mode(self): encode_message_protocol(b"\x01\x00", COMMAND_MCU_SWITCH_TO_SLEEP_MODE))) - if isinstance(self.protocol, protocol.USBProtocol): - check_ack( - check_message_protocol( - check_message_pack(self.protocol.read()), COMMAND_ACK), - COMMAND_MCU_SWITCH_TO_SLEEP_MODE) + self._expect_ack(COMMAND_MCU_SWITCH_TO_SLEEP_MODE) def mcu_switch_to_idle_mode(self, sleep_time: int): print(f"mcu_switch_to_idle_mode({sleep_time})") @@ -309,11 +317,7 @@ def mcu_switch_to_idle_mode(self, sleep_time: int): struct.pack(" Date: Thu, 6 Aug 2026 19:41:35 +0300 Subject: [PATCH 2/5] Add sensor identifier, and identify 27c6:533c as GF5288 on the wrapped protocol 533c (tested on a Dell XPS 13 9310; also reported for the XPS 13 9300 and XPS 15 9500) has no open driver and its chip model cannot be read passively -- the USB descriptors say only "Goodix/FingerPrint". identifier.py asks the sensor directly, trying both message framings. For 533c the result is: Firmware version: GF5288_GM168SEC_APP_13016 Chip ID: 0x220ca1 Sensor speaks the wrapped protocol So it is GF5288 silicon -- same chip as the 53x5 family, and 0x220ca1 >> 8 is the 0x220C that driver_53x5.py already accepts as sensor type 9 -- but a different secure element (GM168SEC vs HTSEC) reached over the wrapped framing rather than the direct one driver_53x5.py is built on. Neither existing driver matches as-is; a 53xc driver is 53x5's device logic on goodix.py's transport. One framing delta found already: goodix.Device.firmware_version() expects an ACK frame before the data reply, and 533c replies with no ACK, so identifier.read_firmware_version() skips ACKs instead of requiring one. Read-only by construction: ping, firmware version, sensor reset and register reads. No firmware erase and no PSK write. Co-Authored-By: Claude Opus 5 (1M context) --- identifier.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++ identify_533c.py | 3 ++ 2 files changed, 80 insertions(+) create mode 100644 identifier.py create mode 100644 identify_533c.py diff --git a/identifier.py b/identifier.py new file mode 100644 index 0000000..5cccb69 --- /dev/null +++ b/identifier.py @@ -0,0 +1,77 @@ +import logging + +import usb.util + +import goodix +import protocol +import wrapless + + +def read_firmware_version(device: goodix.Device): + """Like goodix.Device.firmware_version(), but tolerates sensors that reply + without a preceding ACK frame -- 533c (GF5288_GM168SEC) does exactly that. + """ + device.protocol.write( + goodix.encode_message_pack( + goodix.encode_message_protocol(b"\x00\x00", + goodix.COMMAND_FIRMWARE_VERSION))) + + while True: + message = goodix.check_message_pack(device.protocol.read()) + payload, command, _ = goodix.decode_message_protocol(message) + + if command == goodix.COMMAND_ACK: + continue + + if command != goodix.COMMAND_FIRMWARE_VERSION: + raise ValueError(f"Unexpected command: {command:#04x}") + + return payload.split(b"\x00")[0].decode() + + +def identify_wrapless(product: int): + device = wrapless.Device(product, protocol.USBProtocol) + try: + device.ping() + print(f"Firmware version: {device.read_firmware_version()}") + + device.reset(0, False) + chip_id = wrapless.decode_u32(device.read_data(0, 4, 0.2)) + print(f"Chip ID: {chip_id:#x}") + + finally: + usb.util.dispose_resources(device.protocol.device) + + +def identify_wrapped(product: int): + device = goodix.Device(product, protocol.USBProtocol) + try: + device.nop() + print(f"Firmware version: {read_firmware_version(device)}") + + device.reset(True, False, 20) + chip_id = wrapless.decode_u32(device.read_sensor_register(0x0000, 4)) + print(f"Chip ID: {chip_id:#x}") + + finally: + usb.util.dispose_resources(device.protocol.device) + + +def main(product: int): + """Report which message framing a sensor speaks, and its firmware version. + + Read-only: ping, firmware version and register reads only. Nothing here + erases firmware or writes a PSK. + """ + logging.basicConfig(level=logging.INFO, format="%(message)s") + + for name, identify in (("wrapless", identify_wrapless), + ("wrapped", identify_wrapped)): + print(f"\nTrying {name} protocol...") + + try: + identify(product) + print(f"Sensor speaks the {name} protocol") + + except Exception as exception: + print(f"Not {name}: {type(exception).__name__}: {exception}") diff --git a/identify_533c.py b/identify_533c.py new file mode 100644 index 0000000..96e52f4 --- /dev/null +++ b/identify_533c.py @@ -0,0 +1,3 @@ +import identifier + +identifier.main(0x533C) From 7b248cc7c8b69eb15ca68ff6d421513e95e38983 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Thu, 6 Aug 2026 18:57:35 +0300 Subject: [PATCH 3/5] Add read-only recon tool, and drop the local ACK workaround recon.py reports what a sensor will tell you without writing anything: firmware version, PSK slot state, chip ID, OTP, and a firmware readback attempt. The readback matters because mcu_erase_app is irreversible without an image to flash back and upstream ships none (firmware/ is empty, *.bin is gitignored) -- so it is worth knowing whether a device will hand its firmware over before considering any provisioning. On 27c6:533c it will not: Firmware: GF5288_GM168SEC_APP_13016 PSK 0xbb020003 / 7 / 1 / 2: not present Chip ID: 0c a1 00 22 (0x220ca1 -- what driver_53x5 expects) OTP (32 bytes): 68 e6 86 4a 54 ec 15 04 ... Firmware dumped: 0 bytes (read_firmware times out in APP mode) identifier.py carried its own ACK-tolerant firmware_version reader as a workaround; the previous commit fixes that in goodix.py, so it now calls device.firmware_version() directly. Note the PSK slots report nothing at the flag values 51x7 and 55x4 use. A USB capture of the vendor driver shows it sending a 17-byte preset_psk_read request with extra fields where goodix.py sends 8, which is the likely reason. Co-Authored-By: Claude Opus 5 (1M context) --- identifier.py | 24 +-------------- recon.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++ recon_533c.py | 3 ++ 3 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 recon.py create mode 100644 recon_533c.py diff --git a/identifier.py b/identifier.py index 5cccb69..3713957 100644 --- a/identifier.py +++ b/identifier.py @@ -7,28 +7,6 @@ import wrapless -def read_firmware_version(device: goodix.Device): - """Like goodix.Device.firmware_version(), but tolerates sensors that reply - without a preceding ACK frame -- 533c (GF5288_GM168SEC) does exactly that. - """ - device.protocol.write( - goodix.encode_message_pack( - goodix.encode_message_protocol(b"\x00\x00", - goodix.COMMAND_FIRMWARE_VERSION))) - - while True: - message = goodix.check_message_pack(device.protocol.read()) - payload, command, _ = goodix.decode_message_protocol(message) - - if command == goodix.COMMAND_ACK: - continue - - if command != goodix.COMMAND_FIRMWARE_VERSION: - raise ValueError(f"Unexpected command: {command:#04x}") - - return payload.split(b"\x00")[0].decode() - - def identify_wrapless(product: int): device = wrapless.Device(product, protocol.USBProtocol) try: @@ -47,7 +25,7 @@ def identify_wrapped(product: int): device = goodix.Device(product, protocol.USBProtocol) try: device.nop() - print(f"Firmware version: {read_firmware_version(device)}") + print(f"Firmware version: {device.firmware_version()}") device.reset(True, False, 20) chip_id = wrapless.decode_u32(device.read_sensor_register(0x0000, 4)) diff --git a/recon.py b/recon.py new file mode 100644 index 0000000..2fe69f6 --- /dev/null +++ b/recon.py @@ -0,0 +1,81 @@ +import goodix +import protocol + + +def dump_firmware(device: goodix.Device, chunk: int = 256, limit: int = 0x80000): + """Read the running firmware back off the sensor, until it stops replying. + + This is the prerequisite for any provisioning work: mcu_erase_app is + irreversible without an image to flash back, and upstream ships none + (firmware/ is empty, *.bin is gitignored). + """ + data = b"" + while len(data) < limit: + try: + block = device.read_firmware(len(data), chunk) + except Exception as exception: + print(f" stopped at {len(data):#x}: " + f"{type(exception).__name__}: {exception}") + break + + if not block: + break + + data += block + + return data + + +def attempt(label: str, function): + try: + return function() + except Exception as exception: + print(f"{label}: FAILED {type(exception).__name__}: {exception}") + return None + + +def main(product: int): + """Read-only reconnaissance: firmware, PSK state, OTP, firmware readback. + + No firmware erase and no PSK write -- nothing here can brick the sensor. + """ + device = goodix.Device(product, protocol.USBProtocol) + + device.nop() + attempt("Enable chip", lambda: device.enable_chip(True)) + device.nop() + + firmware = attempt("Firmware", device.firmware_version) + print(f"Firmware: {firmware}") + + # the flags value differs per sensor: 51x7 uses 0xbb020003, 55x4 0xbb020007 + for flags in (0xbb020003, 0xbb020007, 0xbb020001, 0xbb020002): + result = attempt(f"PSK {flags:#x}", lambda: device.preset_psk_read(flags)) + if result is None: + continue + + success, reply_flags, psk_hash = result + if not success: + print(f"PSK {flags:#x}: not present") + else: + print(f"PSK {flags:#x}: flags={reply_flags:#x} hash={psk_hash.hex()}") + + attempt("Reset", lambda: device.reset(True, False, 20)) + + chip_id = attempt("Chip ID", lambda: device.read_sensor_register(0x0000, 4)) + if chip_id is not None: + print(f"Chip ID: {chip_id.hex(' ')}") + + otp = attempt("OTP", device.read_otp) + if otp is not None: + print(f"OTP ({len(otp)} bytes): {otp.hex(' ')}") + + print("Dumping firmware...") + image = dump_firmware(device) + print(f"Firmware dumped: {len(image)} bytes") + + if image: + path = f"firmware/{firmware}.bin" + with open(path, "wb") as file: + file.write(image) + print(f"Wrote {path}") diff --git a/recon_533c.py b/recon_533c.py new file mode 100644 index 0000000..5506866 --- /dev/null +++ b/recon_533c.py @@ -0,0 +1,3 @@ +import recon + +recon.main(0x533C) From b265a426b21955bc1414743a4bcb766d9c30f93e Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Thu, 6 Aug 2026 20:06:29 +0300 Subject: [PATCH 4/5] Read the PSK slots correctly, and flag the all-zero PSK preset_psk_read takes optional length and offset. Without them goodix.py sends the 8-byte short form and 533c answers "not present" even though a PSK is there -- which is what recon.py was doing, and why it reported empty slots. A USB capture of the vendor driver shows it sending the 16-byte form, and supplying length/offset makes goodix.py behave identically: preset_psk_read(0xbb020001, 32, 0) -> 32-byte hash preset_psk_read(0xbb010002, 102, 0) -> 102-byte wrapped PSK blob On 533c the hash is: 66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925 which is sha256 of 32 zero bytes -- the sensor already holds the all-zero PSK that driver_51x7 and driver_55x4 provision. recon.py now says so explicitly, because knowing it beforehand means not needing preset_psk_write at all, and on this device provisioning is irreversible (read_firmware returns nothing in APP mode, so there is no image to flash back). Co-Authored-By: Claude Opus 5 (1M context) --- recon.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/recon.py b/recon.py index 2fe69f6..eb36f3f 100644 --- a/recon.py +++ b/recon.py @@ -1,6 +1,12 @@ +import hashlib + import goodix import protocol +# what preset_psk_read reports when the sensor holds the all-zero PSK that +# driver_51x7 / driver_55x4 provision -- worth knowing before writing one +ALL_ZERO_PSK_HASH = hashlib.sha256(bytes(32)).digest() + def dump_firmware(device: goodix.Device, chunk: int = 256, limit: int = 0x80000): """Read the running firmware back off the sensor, until it stops replying. @@ -48,17 +54,26 @@ def main(product: int): firmware = attempt("Firmware", device.firmware_version) print(f"Firmware: {firmware}") - # the flags value differs per sensor: 51x7 uses 0xbb020003, 55x4 0xbb020007 - for flags in (0xbb020003, 0xbb020007, 0xbb020001, 0xbb020002): - result = attempt(f"PSK {flags:#x}", lambda: device.preset_psk_read(flags)) + # preset_psk_read needs length and offset: without them goodix.py sends the + # short form and the sensor answers "not present" even when a PSK is there. + # Slots differ per sensor; 533c answers 0xbb020001 (hash) and 0xbb010002. + for flags, length in ((0xbb020001, 32), (0xbb010002, 102), + (0xbb020003, 32), (0xbb020007, 32)): + result = attempt(f"PSK {flags:#x}", + lambda: device.preset_psk_read(flags, length, 0)) if result is None: continue - success, reply_flags, psk_hash = result + success, reply_flags, data = result if not success: print(f"PSK {flags:#x}: not present") - else: - print(f"PSK {flags:#x}: flags={reply_flags:#x} hash={psk_hash.hex()}") + continue + + note = "" + if data == ALL_ZERO_PSK_HASH: + note = " <- sha256 of 32 zero bytes: sensor holds the all-zero PSK" + print(f"PSK {flags:#x}: flags={reply_flags:#x} " + f"data({len(data)})={data.hex()}{note}") attempt("Reset", lambda: device.reset(True, False, 20)) From eee3d4a967fb441b0f944cc57597ff4a74a76983 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Thu, 6 Aug 2026 22:06:34 +0300 Subject: [PATCH 5/5] Add driver_53xc.py: capture images from 27c6:533c 533c is GF5288 silicon (chip 0x220ca1, same as driver_53x5 expects) with a GM168SEC secure element, speaking the wrapped framing rather than the direct one. So this is 53x5's device logic on 51x7's transport. Verified end to end on a Dell XPS 13 9310: Firmware: GF5288_GM168SEC_APP_13016 PSK: all-zero, as expected Reset OK, number 1024 Chip ID: 0c a1 00 22 TLS established FDT template: 9a 9a a6 a6 a2 a2 98 98 97 97 a4 a4 a1 a1 ... Capturing reference frame (no finger)... gain 0xc2: 14334 B encrypted -> 14260 B plain Waiting for finger -- touch the sensor... gain 0x86: 14334 B encrypted -> 14260 B plain Wrote fingerprint.pgm Four things differ from the existing wrapped drivers: 1. No provisioning. 533c already holds the all-zero PSK, and read_firmware returns nothing in APP mode, so there is no image to flash back if a write went wrong. The driver checks the PSK hash and refuses to run if it does not match, rather than calling preset_psk_write. 2. TLS application data is decrypted in process from the session keys, derived from the randoms observed while relaying the handshake, rather than read back from openssl's stdout. openssl is still used as the TLS server. This keeps the image bytes on a single code path that can be tested offline against a capture, instead of depending on how the subprocess frames what it echoes. 3. The FDT threshold template is derived, not hardcoded. The baseline reply is 12-bit samples; halving each and emitting it twice reproduces exactly what the vendor driver sends. 4. Finger detection polls. The sensor answers fdt_down only when a finger lands, well past the 5 s default USB read timeout. Images need a reference frame subtracted -- raw captures are dominated by fixed-pattern noise. Fitting scale and offset by least squares absorbs the gain difference too, so one reference frame serves every exposure (the vendor sweeps gain 0xc2 / 0xad / 0x86). Co-Authored-By: Claude Opus 5 (1M context) --- driver_53xc.py | 316 +++++++++++++++++++++++++++++++++++++++++++++++++ run_533c.py | 3 + 2 files changed, 319 insertions(+) create mode 100644 driver_53xc.py create mode 100644 run_533c.py diff --git a/driver_53xc.py b/driver_53xc.py new file mode 100644 index 0000000..e8ac8dc --- /dev/null +++ b/driver_53xc.py @@ -0,0 +1,316 @@ +"""Driver for Goodix 27c6:533c -- GF5288 silicon on the wrapped protocol. + +533c is the same GF5288 as the 53x5 family (chip 0x220ca1) but pairs it with a +GM168SEC secure element and speaks the wrapped framing from goodix.py rather +than the direct framing driver_53x5.py uses. So it is 53x5's device logic on +51x7's transport. + +Unlike the other drivers here it never writes a PSK or touches firmware. 533c +ships with the all-zero PSK already provisioned, and read_firmware returns +nothing in APP mode -- there is no image to flash back, so mcu_erase_app would +be a one-way trip. If the PSK is not the expected one this driver refuses to +run rather than provisioning. + +Sensor is 108x88, 12 bpp. Raw frames are dominated by fixed-pattern noise; a +reference frame captured with no finger is subtracted with a least-squares +scale and offset, which also absorbs the gain difference between captures. +""" +import hashlib +import hmac +import re +import socket +import statistics +import struct +import subprocess +import time + +import usb.core + +from Crypto.Cipher import AES + +import goodix +import protocol +import tool + +TARGET_FIRMWARE = "GF5288_GM168SEC_APP_13016" +VALID_FIRMWARE = r"GF5288_GM168SEC_APP_1[0-9]{4}" + +PSK = bytes(32) +PSK_HASH = hashlib.sha256(PSK).digest() +PSK_FLAGS, PSK_LENGTH = 0xbb020001, 32 + +SENSOR_WIDTH, SENSOR_HEIGHT = 108, 88 +IMAGE_BYTES = SENSOR_WIDTH * SENSOR_HEIGHT * 3 // 2 + +# captured from the vendor driver on an XPS 13 9310 +DEVICE_CONFIG = bytes.fromhex( + "40116c7d28a528cd1ce910f900f900f9" + "000402000008001111ba000180ca0007" + "008400beb28600c5b98800b5ad8a009d" + "958c0000be8e0000c5900000b5920000" + "9d940000af960000bf980000b69a0000" + "a730006c1c50000105d0000000700000" + "00720078567400341226000012200010" + "4012000304020216212c020a032a0102" + "002200012024003200800005045c0000" + "01560028205800010032002402820080" + "0c2002880d2a01920722000120240014" + "00800005045c00940056000820580003" + "0032000804820080112002280c2a0118" + "045c0094005400000162000903640018" + "008200800c2002280c2a0118045c0094" + "00520008005400000100000000005113") + +CAPTURE_REGISTER = 0x022c # toggled around every capture +CAPTURE_ON, CAPTURE_OFF = b"\x0a\x03", b"\x0a\x02" + +FDT_MODE_IDLE = b"\x0d\x01" # measure baseline, do not arm +FDT_MODE_ARMED = b"\x8d\x01" +FDT_DOWN_ARMED = b"\x0c\x01" +FDT_UP_ARMED = b"\x0e\x01" + + +def init_device(product: int): + device = goodix.Device(product, protocol.USBProtocol) + + device.nop() + firmware = device.firmware_version() + print(f"Firmware: {firmware}") + + if not re.fullmatch(VALID_FIRMWARE, firmware): + raise ValueError(f"Unsupported firmware: {firmware}") + + success, _, psk_hash = device.preset_psk_read(PSK_FLAGS, PSK_LENGTH, 0) + if not success or psk_hash != PSK_HASH: + raise ValueError( + "Sensor does not hold the all-zero PSK. This driver will not " + "provision one: read_firmware returns nothing in APP mode, so a " + "failed write could not be recovered from.") + print("PSK: all-zero, as expected") + + return device + + +def fdt_template(baseline: bytes): + """Build an FDT threshold template from a measured baseline. + + The baseline reply is a 4-byte header then 12-bit samples as 16-bit LE + words. The vendor driver halves each sample and emits it twice, which is + what the sensor expects back as the finger-detect threshold. + """ + samples = [ + struct.unpack("> 1) * 2 + + return template + + +def measure_baseline(device: goodix.Device, mode: bytes = FDT_MODE_IDLE): + reply = device.mcu_switch_to_fdt_mode(mode + bytes(24), True) + return fdt_template(reply) + + +def prf(secret: bytes, label: bytes, seed: bytes, length: int): + """TLS 1.2 PRF with SHA-256.""" + out, a = b"", label + seed + while len(out) < length: + a = hmac.new(secret, a, hashlib.sha256).digest() + out += hmac.new(secret, a + label + seed, hashlib.sha256).digest() + return out[:length] + + +def session_keys(client_random: bytes, server_random: bytes): + """Derive the TLS-PSK session keys for suite 0x00AE. + + RFC 4279 premaster for a pure-PSK suite is len||zeros||len||psk. The key + block for TLS_PSK_WITH_AES_128_CBC_SHA256 is two 32-byte MAC keys then two + 16-byte cipher keys; TLS 1.2 carries the IV explicitly per record. + """ + n = len(PSK) + premaster = (struct.pack(">H", n) + bytes(n) + struct.pack(">H", n) + PSK) + + master = prf(premaster, b"master secret", client_random + server_random, 48) + block = prf(master, b"key expansion", server_random + client_random, 96) + + return block[64:80], block[80:96] # client_write_key, server_write_key + + +def tls_random(record: bytes, handshake_type: int): + """Pull the 32-byte random out of a ClientHello/ServerHello record.""" + if record[0] != 0x16 or record[5] != handshake_type: + raise ValueError(f"Not handshake type {handshake_type}: {record[:8].hex()}") + + return record[11:43] + + +def establish_tls(device: goodix.Device, tls_client: socket.socket): + """Relay the handshake through openssl, keeping the randoms. + + openssl is only the TLS server here; application data is decrypted in + process from the session keys instead of being read back from the + subprocess's stdout. That avoids depending on how openssl frames what it + echoes, and keeps the image bytes on one code path we can test offline. + """ + hello = device.request_tls_connection() + client_random = tls_random(hello, 0x01) + tls_client.sendall(hello) + + server_hello = tls_client.recv(4096) + server_random = tls_random(server_hello, 0x02) + device.protocol.write( + goodix.encode_message_pack(server_hello, + goodix.FLAGS_TRANSPORT_LAYER_SECURITY)) + + for _ in range(3): # ClientKeyExchange, CCS, Finished + tls_client.sendall( + goodix.check_message_pack(device.read(), + goodix.FLAGS_TRANSPORT_LAYER_SECURITY)) + + device.protocol.write( + goodix.encode_message_pack(tls_client.recv(4096), + goodix.FLAGS_TRANSPORT_LAYER_SECURITY)) + + return session_keys(client_random, server_random) + + +def decrypt_record(record: bytes, key: bytes): + """AES-128-CBC with an explicit IV, then strip padding and the MAC.""" + if record[0] != 0x17: + raise ValueError(f"Not application data: {record[0]:#04x}") + + body = record[5:] + plain = AES.new(key, AES.MODE_CBC, body[:16]).decrypt(body[16:]) + + pad = plain[-1] + if pad + 1 > len(plain) or any(b != pad for b in plain[-(pad + 1):]): + raise ValueError("Bad CBC padding -- wrong key?") + + return plain[:-(pad + 1)][:-32] # drop padding, then HMAC-SHA256 + + +def wait_for_finger(device: goodix.Device, template: bytes, timeout: float = 30): + """Arm finger detection and block until the sensor reports a touch. + + The sensor answers fdt_down only once a finger lands, which is well past + the 5 s default USB read timeout, so poll instead of waiting in one read. + """ + device.mcu_switch_to_fdt_down(template, False) + + deadline = time.time() + timeout + while time.time() < deadline: + try: + return goodix.check_message_protocol( + goodix.check_message_pack(device.read(timeout=2)), + goodix.COMMAND_MCU_SWITCH_TO_FDT_DOWN) + except usb.core.USBTimeoutError: + continue + + raise TimeoutError("No finger detected") + + +def capture(device: goodix.Device, key: bytes, flags: int, gain: int): + """One frame: enable capture, request an image, disable capture.""" + device.write_sensor_register(CAPTURE_REGISTER, CAPTURE_ON) + + frame = device.mcu_get_image( + struct.pack(" {len(plain)} B plain") + if len(plain) < IMAGE_BYTES: + raise ValueError(f"Short decrypt: {len(plain)} < {IMAGE_BYTES}") + + return tool.decode_image(plain[:IMAGE_BYTES]) + + +def flat_field(frame: list[int], reference: list[int]): + """Subtract a*reference+b, a and b by least squares. + + Fitting the scale absorbs the gain difference between the reference and + the live capture, so one reference frame serves every exposure. + """ + mean_frame = statistics.mean(frame) + mean_reference = statistics.mean(reference) + variance = sum((v - mean_reference)**2 for v in reference) or 1 + + a = sum((frame[i] - mean_frame) * (reference[i] - mean_reference) + for i in range(len(frame))) / variance + b = mean_frame - a * mean_reference + + return [int(frame[i] - (a * reference[i] + b)) for i in range(len(frame))] + + +def run_driver(device: goodix.Device): + errors = open("/tmp/openssl-53xc.log", "w+b") + tls_server = subprocess.Popen([ + "openssl", "s_server", "-nocert", "-psk", + PSK.hex(), "-port", "4433", "-quiet" + ], + stdout=subprocess.PIPE, + stderr=errors) + + try: + success, number = device.reset(True, False, 20) + if not success: + raise ValueError("Reset failed") + print(f"Reset OK, number {number}") + + chip_id = device.read_sensor_register(0x0000, 4) + print(f"Chip ID: {chip_id.hex(' ')}") + + otp = device.read_otp() + if len(otp) < 32: + raise ValueError("Invalid OTP") + print(f"OTP: {otp.hex(' ')}") + + tls_client = socket.socket() + tls_client.connect(("localhost", 4433)) + + try: + client_write_key, _ = establish_tls(device, tls_client) + device.tls_successfully_established() + print("TLS established") + + if not device.upload_config_mcu(DEVICE_CONFIG): + raise ValueError("Failed to upload config") + + template = measure_baseline(device) + print(f"FDT template: {template.hex(' ')}") + + print("Capturing reference frame (no finger)...") + reference = capture(device, client_write_key, 0x01, 0xc2) + + device.mcu_switch_to_sleep_mode() + device.query_mcu_state(b"\x01\x00\x01", False) + + print("Waiting for finger -- touch the sensor...") + wait_for_finger(device, FDT_DOWN_ARMED + template) + device.mcu_switch_to_fdt_mode(FDT_MODE_ARMED + template, True) + + image = capture(device, client_write_key, 0x41, 0x86) + + device.mcu_switch_to_fdt_up(FDT_UP_ARMED + template) + + corrected = flat_field(image, reference) + tool.write_pgm(corrected, SENSOR_WIDTH, SENSOR_HEIGHT, + "fingerprint.pgm") + print("Wrote fingerprint.pgm") + + finally: + tls_client.close() + finally: + tls_server.terminate() + + +def main(product: int): + device = init_device(product) + run_driver(device) diff --git a/run_533c.py b/run_533c.py new file mode 100644 index 0000000..03d3f63 --- /dev/null +++ b/run_533c.py @@ -0,0 +1,3 @@ +import driver_53xc + +driver_53xc.main(0x533C)