Skip to content
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Identification (1:N) support: one capture is matched against the whole
gallery, so any enrolled finger can match. fprintd only does multi-finger
matching through identify; without it, "any"-finger verification (lock
screen, sudo) only ever checked the first enrolled print. Also enables
fprintd's duplicate-print detection at enrollment start.

### Fixed

- Cancellation support: pending verify/enroll/identify operations now stop at
packet boundaries. fprintd previously could not stop a pending verify (e.g.
when the lock screen tears down its PAM conversation), leaking the device
claim ("Device was already claimed") until the daemon was SIGKILLed and the
reader USB-reset.
- Verify polling throttled to the same 100 ms cadence as enroll instead of
busy-polling the sensor over USB for as long as a verification stays pending.
- Verify retry errors are reported via fpi_device_verify_report() so a bad
finger placement stays recoverable instead of failing the operation.
- Disabled libfprint's time-based overheating estimator (temp_hot_seconds =
-1), which produced false TOO_HOT lockouts on this external reader.
- FID slots 30/31 are never selected from the ReadIndex bitmap; StoreChar
fails with status 0x18 for them.
- Plugged a GVariant leak on every verify.

## [1.0.0] - 2026-03-23

### Added
Expand Down
241 changes: 233 additions & 8 deletions src/microarray.c
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ struct _FpiDeviceMicroarray
guint8 *resp_buf; /* allocated response buffer */
GCancellable *interrupt_cancellable;
gboolean waiting_for_lift; /* TRUE after each successful capture */
guint identify_index; /* gallery print currently being searched */
};

G_DECLARE_FINAL_TYPE (FpiDeviceMicroarray, fpi_device_microarray,
Expand Down Expand Up @@ -312,6 +313,16 @@ cmd_send_cb (FpiUsbTransfer *transfer, FpDevice *device,
fpi_ssm_next_state (ssm);
}

/*
* Transfers deliberately pass a NULL cancellable: this is a strict
* command/response protocol, and cancelling a transfer mid round-trip
* leaves an orphaned response queued on the bulk-IN endpoint that
* desynchronizes every subsequent exchange. Cancellation is honoured
* at packet boundaries instead (fpi_device_action_is_cancelled checks
* in the GET_IMAGE polling loops); every transfer is already bounded
* by MA_TIMEOUT_CMD, so nothing can block indefinitely.
*/

/* Submit a bulk-OUT command */
static void
ma_submit_cmd (FpiSsm *ssm, FpDevice *device,
Expand Down Expand Up @@ -423,10 +434,16 @@ enroll_run_state (FpiSsm *ssm, FpDevice *device)
/* Check bitmap from pre-enrollment ReadIndex */
const guint8 *resp = self->resp_buf + MA_OVERHEAD;
if (resp[0] == 0x00) {
/* Only 30 template slots exist (0-29). Some ReadIndex responses
* expose a 32-bit bitmap; StoreChar fails with 0x18 for slots
* 30/31, so never select them. */
for (int byte = 0; byte < 4 && self->fid < 0; byte++) {
for (int bit = 0; bit < 8; bit++) {
int candidate_fid = byte * 8 + bit;
if (candidate_fid >= 30)
break;
if (!(resp[1 + byte] & (1 << bit))) {
self->fid = byte * 8 + bit;
self->fid = candidate_fid;
break;
}
}
Expand Down Expand Up @@ -460,6 +477,12 @@ enroll_run_state (FpiSsm *ssm, FpDevice *device)
break;

case ENROLL_GEN_CHAR:
if (fpi_device_action_is_cancelled (device)) {
fpi_ssm_mark_failed (ssm,
g_error_new_literal (G_IO_ERROR, G_IO_ERROR_CANCELLED,
"Enrollment cancelled"));
return;
}
if (self->resp_buf[MA_OVERHEAD] != 0x00) {
/* No image (finger absent or sensor not ready) */
if (self->waiting_for_lift) {
Expand Down Expand Up @@ -621,6 +644,13 @@ enum {
VERIFY_NUM_STATES,
};

static gboolean
verify_poll_get_image_cb (gpointer user_data)
{
fpi_ssm_jump_to_state (user_data, VERIFY_GET_IMAGE);
return G_SOURCE_REMOVE;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
static void
verify_run_state (FpiSsm *ssm, FpDevice *device)
{
Expand All @@ -639,9 +669,15 @@ verify_run_state (FpiSsm *ssm, FpDevice *device)
break;

case VERIFY_GEN_CHAR:
if (fpi_device_action_is_cancelled (device)) {
fpi_ssm_mark_failed (ssm,
g_error_new_literal (G_IO_ERROR, G_IO_ERROR_CANCELLED,
"Verification cancelled"));
return;
}
if (self->resp_buf[MA_OVERHEAD] != 0x00) {
fp_dbg ("GetImage not ready, retrying");
fpi_ssm_jump_to_state (ssm, VERIFY_GET_IMAGE);
g_timeout_add (100, verify_poll_get_image_cb, ssm);
return;
}
fpi_device_report_finger_status (device, FP_FINGER_STATUS_PRESENT);
Expand All @@ -667,6 +703,7 @@ verify_run_state (FpiSsm *ssm, FpDevice *device)
g_object_get (print, "fpi-data", &data, NULL);
gint fid = 0;
g_variant_get (data, "(i)", &fid);
g_variant_unref (data);
self->fid = fid;

/* CMD 0x66 fid_hi fid_lo — verify against specific FID */
Expand Down Expand Up @@ -694,8 +731,12 @@ verify_ssm_done (FpiSsm *ssm, FpDevice *device, GError *error)
fpi_device_report_finger_status (device, FP_FINGER_STATUS_NONE);

if (error) {
/* Could be a retry error */
fpi_device_verify_complete (device, error);
if (error->domain == FP_DEVICE_RETRY) {
fpi_device_verify_report (device, FPI_MATCH_ERROR, NULL, error);
fpi_device_verify_complete (device, NULL);
} else {
fpi_device_verify_complete (device, error);
}
return;
}

Expand All @@ -721,6 +762,188 @@ ma_verify (FpDevice *device)
fpi_ssm_start (ssm, verify_ssm_done);
}

/* --------------------------------------------------------------------------
* Identify state machine
* --------------------------------------------------------------------------
*
* The chip has no native 1:N search command: CMD 0x66 only matches the
* char buffer against a single FID. Identification therefore captures
* one image and walks the gallery, re-running GenChar before each
* Search because a Search invalidates the char buffer (the image
* buffer persists, so no re-capture is needed).
*/

enum {
IDENTIFY_GET_IMAGE,
IDENTIFY_RECV_IMAGE,
IDENTIFY_CHECK_IMAGE,
IDENTIFY_GEN_CHAR,
IDENTIFY_RECV_GEN_CHAR,
IDENTIFY_SEARCH,
IDENTIFY_RECV_SEARCH,
IDENTIFY_CHECK_MATCH,
IDENTIFY_NUM_STATES,
};

static gboolean
identify_poll_get_image_cb (gpointer user_data)
{
fpi_ssm_jump_to_state (user_data, IDENTIFY_GET_IMAGE);
return G_SOURCE_REMOVE;
}

static void
identify_run_state (FpiSsm *ssm, FpDevice *device)
{
FpiDeviceMicroarray *self = FPI_DEVICE_MICROARRAY (device);
guint8 cmd[8];

switch (fpi_ssm_get_cur_state (ssm)) {

case IDENTIFY_GET_IMAGE:
cmd[0] = MA_CMD_GET_IMAGE;
ma_submit_cmd (ssm, device, cmd, 1);
break;

case IDENTIFY_RECV_IMAGE:
ma_submit_recv (ssm, device, MA_OVERHEAD + 3 + 2);
break;

case IDENTIFY_CHECK_IMAGE:
if (fpi_device_action_is_cancelled (device)) {
fpi_ssm_mark_failed (ssm,
g_error_new_literal (G_IO_ERROR, G_IO_ERROR_CANCELLED,
"Identification cancelled"));
return;
}
if (self->resp_buf[MA_OVERHEAD] != 0x00) {
fp_dbg ("GetImage not ready, retrying");
g_timeout_add (100, identify_poll_get_image_cb, ssm);
return;
}
fpi_device_report_finger_status (device, FP_FINGER_STATUS_PRESENT);
fpi_ssm_next_state (ssm);
break;

case IDENTIFY_GEN_CHAR:
/* Extract features from the captured image into char buffer
* slot 1; re-entered after every non-matching Search. */
cmd[0] = MA_CMD_GEN_CHAR;
cmd[1] = 0x01;
ma_submit_cmd (ssm, device, cmd, 2);
break;

case IDENTIFY_RECV_GEN_CHAR:
ma_submit_recv (ssm, device, MA_OVERHEAD + 3 + 2);
break;

case IDENTIFY_SEARCH: {
if (self->resp_buf[MA_OVERHEAD] != 0x00) {
fpi_ssm_mark_failed (ssm,
fpi_device_retry_new (FP_DEVICE_RETRY_GENERAL));
return;
}
GPtrArray *prints = NULL;
fpi_device_get_identify_data (device, &prints);
FpPrint *print = g_ptr_array_index (prints, self->identify_index);
GVariant *data = NULL;
g_object_get (print, "fpi-data", &data, NULL);
gint fid = 0;
g_variant_get (data, "(i)", &fid);
g_variant_unref (data);
self->fid = fid;

fp_dbg ("identify: searching FID %d (gallery print %u of %u)",
self->fid, self->identify_index + 1, prints->len);
cmd[0] = MA_CMD_SEARCH;
cmd[1] = (guint8)(self->fid >> 8);
cmd[2] = (guint8)(self->fid & 0xFF);
ma_submit_cmd (ssm, device, cmd, 3);
break;
}

case IDENTIFY_RECV_SEARCH:
ma_submit_recv (ssm, device, MA_OVERHEAD + 3 + 2);
break;

case IDENTIFY_CHECK_MATCH: {
GPtrArray *prints = NULL;
fpi_device_get_identify_data (device, &prints);

if (self->resp_buf[MA_OVERHEAD] == 0x00) {
fp_dbg ("identify: matched gallery print %u", self->identify_index);
fpi_ssm_mark_completed (ssm);
return;
}
self->identify_index++;
if (self->identify_index < prints->len) {
/* Search consumed the char buffer — regenerate it from the
* still-valid image buffer before matching the next FID. */
fpi_ssm_jump_to_state (ssm, IDENTIFY_GEN_CHAR);
return;
}
fp_dbg ("identify: no gallery print matched");
fpi_ssm_mark_completed (ssm);
return;
}

default:
g_assert_not_reached ();
}
}

static void
identify_ssm_done (FpiSsm *ssm, FpDevice *device, GError *error)
{
FpiDeviceMicroarray *self = FPI_DEVICE_MICROARRAY (device);

fpi_device_report_finger_status (device, FP_FINGER_STATUS_NONE);

if (error) {
if (error->domain == FP_DEVICE_RETRY) {
fpi_device_identify_report (device, NULL, NULL, error);
fpi_device_identify_complete (device, NULL);
} else {
fpi_device_identify_complete (device, error);
}
return;
}

GPtrArray *prints = NULL;
fpi_device_get_identify_data (device, &prints);

/* resp_buf still holds the last Search response: it is 0x00 exactly
* when the loop stopped on a match at identify_index. */
if (self->resp_buf[MA_OVERHEAD] == 0x00 &&
self->identify_index < prints->len) {
FpPrint *match = g_ptr_array_index (prints, self->identify_index);
fpi_device_identify_report (device, match, NULL, NULL);
} else {
fpi_device_identify_report (device, NULL, NULL, NULL);
}
fpi_device_identify_complete (device, NULL);
}

static void
ma_identify (FpDevice *device)
{
FpiDeviceMicroarray *self = FPI_DEVICE_MICROARRAY (device);
GPtrArray *prints = NULL;

fpi_device_get_identify_data (device, &prints);
if (!prints || prints->len == 0) {
/* Nothing to match against (e.g. fprintd's duplicate check at
* first enrollment) — report a clean no-match. */
fpi_device_identify_report (device, NULL, NULL, NULL);
fpi_device_identify_complete (device, NULL);
return;
}

self->identify_index = 0;
FpiSsm *ssm = fpi_ssm_new (device, identify_run_state, IDENTIFY_NUM_STATES);
fpi_ssm_start (ssm, identify_ssm_done);
}

/* --------------------------------------------------------------------------
* GObject boilerplate
* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -761,11 +984,13 @@ fpi_device_microarray_class_init (FpiDeviceMicroarrayClass *klass)
dev_class->id_table = id_table;
dev_class->nr_enroll_stages = MA_ENROLL_SAMPLES;
dev_class->scan_type = FP_SCAN_TYPE_PRESS;
dev_class->temp_hot_seconds = -1;

dev_class->open = ma_dev_open;
dev_class->close = ma_dev_close;
dev_class->enroll = ma_enroll;
dev_class->verify = ma_verify;
dev_class->open = ma_dev_open;
dev_class->close = ma_dev_close;
dev_class->enroll = ma_enroll;
dev_class->verify = ma_verify;
dev_class->identify = ma_identify;

fpi_device_class_auto_initialize_features (dev_class);
}