Skip to content

Add touchid_system_config and touchid_user_config tables#110

Open
robbiet480 wants to merge 10 commits into
macadmins:mainfrom
robbiet480:add-touchid-tables
Open

Add touchid_system_config and touchid_user_config tables#110
robbiet480 wants to merge 10 commits into
macadmins:mainfrom
robbiet480:add-touchid-tables

Conversation

@robbiet480

@robbiet480 robbiet480 commented Jun 2, 2026

Copy link
Copy Markdown

Adds two macOS Touch ID tables, populated from bioutil(1) and ioreg(8). Apple Silicon only.

touchid_system_config

Machine-wide Touch ID / Secure Enclave posture:

Column Source Meaning
touchid_compatible bioutil -r -s Secure Enclave biometric support
secure_enclave sysctl -n hw.model SoC model identifier
touchid_enabled bioutil -r -s biometrics functionality enabled
touchid_unlock bioutil -r -s biometrics-for-unlock enabled
touchid_builtin ioreg -a -r -c AppleBiometricSensor a built-in Touch ID sensor exists (laptops)
touchid_sensor_present built-in or ioreg -a -r -c AppleMesaAccessory a usable sensor exists: built-in OR an attached Touch ID accessory

Why the two new presence columns

touchid_compatible / touchid_enabled both derive from bioutil's Biometrics functionality flag, which reports the on-die Secure Enclave — present on every Apple Silicon Mac, including a keyboard-less Mac mini or Mac Studio that has no fingerprint sensor at all. So neither column can answer "does this Mac actually have a usable Touch ID sensor." touchid_sensor_present is the signal callers want for "can this user enroll a fingerprint":

  • Built-in sensors (laptops) register one or more AppleBiometricSensor IORegistry nodes.
  • External sensors (e.g. a Magic Keyboard with Touch ID) register no AppleBiometricSensor node, but do register an AppleMesaAccessory node. "Mesa" is Apple's codename for the Touch ID sensor subsystem; the Accessory class exists only when an external sensor is connected.

AppleMesaAccessory is a capability class, not a product-string match, so it is name-, transport- (USB or Bluetooth), and localization-independent — and a non-Touch-ID keyboard (old or new) correctly reads as no sensor. The sibling classes AppleMesaSEPDriver / AppleMesaResources are not usable for this; they are SEP scaffolding present on every Apple Silicon Mac.

Verified on hardware across five states:

Hardware touchid_compatible touchid_builtin touchid_sensor_present
MacBook (built-in) 1 1 1
Mac Studio + connected Touch ID keyboard 1 0 1
Mac Studio / mini, no Touch ID keyboard 1 0 0
iMac + disconnected/dead Touch ID keyboard 1 0 0

Note touchid_compatible is 1 in every row — including the sensor-less ones — which is the whole reason the presence columns exist. The accessory half of touchid_sensor_present is a live signal: a sleeping/disconnected Touch ID keyboard reads 0 until it reconnects (it is the user's actual ability to use Touch ID right now).

touchid_user_config

Per-user Touch ID configuration and enrolled fingerprint count: uid, fingerprints_registered, touchid_unlock, touchid_applepay, effective_unlock, effective_applepay. Without a WHERE uid = constraint, returns a row per real local account (uid 501–60000, enumerated via dscl . -list /Users UniqueID).

No usable Touch ID sensor → no rows. User enumeration via dscl is independent of the hardware, so a keyboard-less Mac mini/Studio would otherwise emit a NULL-filled row per local account. This table now gates on sensor presence (the same built-in-OR-accessory ioreg check behind touchid_system_config.touchid_sensor_present, factored into a shared SensorPresent helper) and returns no rows at all when there is no usable sensor — handled in code, not punted to callers. The gate short-circuits before any dscl/bioutil work, so it holds even with an explicit WHERE uid = constraint.

The two data sources have different access models:

  • fingerprints_registeredbioutil -c -s (needs root — the context an osquery extension runs in — and reads all users at once regardless of login state).
  • config flags ← bioutil -r run in the target user's login session via launchctl asuser (needs the user logged in).

When a column can't be read it is left NULL rather than 0, so an enabled-but-logged-out user is never misreported as disabled. There is also a workaround for a long-standing bioutil quirk where the effective_* flags can report 1 with zero enrolled fingerprints — forced to 0 only when the count is known to be 0.

Implementation notes

  • Both tables shell out via the injected utils.CmdRunner and are unit-tested with MultiMockCmdRunner — no real binaries are invoked in tests.
  • ioreg is parsed as plist (ioreg -a + micromdm/plist), matching the alt_system_info convention. Presence is len(plist array) > 0; an absent class yields empty output.
  • Registered in main.go's darwinPlugins; BUILD.bazel files generated by gazelle; VERSION bumped 1.4.1 → 1.5.0; README updated.

Testing

  • bazel test //... / go test ./... → all pass (includes no-sensor coverage for touchid_user_config, both with and without a uid constraint)
  • bazel build //:osquery-extension-mac-{arm,amd} + linux-amd → all build
  • golangci-lint run → 0 issues; gofmt clean

🤖 Generated with Claude Code

Two macOS Touch ID tables populated from bioutil(1) and ioreg(8):

  - touchid_system_config: machine-wide Touch ID / Secure Enclave posture
    (touchid_compatible, secure_enclave, touchid_enabled, touchid_unlock)
    plus two sensor-presence columns:
      * touchid_builtin -- a built-in Touch ID sensor exists (laptops),
        detected via an AppleBiometricSensor IORegistry node.
      * touchid_sensor_present -- a usable sensor exists: built-in OR an
        attached external Touch ID accessory (e.g. a Magic Keyboard with
        Touch ID), the latter detected via the AppleMesaAccessory class.

    Why the new columns: touchid_compatible / touchid_enabled come from
    bioutil's "Biometrics functionality" flag, which reports the on-die
    Secure Enclave -- true on every Apple Silicon Mac, including a
    keyboard-less Mac mini/Studio that has no fingerprint sensor at all.
    touchid_sensor_present is the signal callers actually want for "can
    this user enroll a fingerprint." AppleMesaAccessory is a capability
    class, not a product-string match, so it is name-, transport-
    (USB/Bluetooth), and localization-independent, and a non-Touch-ID
    keyboard correctly reads as no sensor. Verified on hardware across
    MacBook (built-in), Mac Studio + Touch ID keyboard, keyboard-less
    Mac mini/Studio, and an iMac with a disconnected Touch ID keyboard.

  - touchid_user_config: per-user Touch ID configuration and enrolled
    fingerprint count (uid, fingerprints_registered, touchid_unlock,
    touchid_applepay, effective_unlock, effective_applepay). Without a
    WHERE uid = constraint it returns a row per real local account.
    fingerprints_registered comes from `bioutil -c -s` (needs root, reads
    all users at once); the config flags come from `bioutil -r` run in the
    user's login session via launchctl asuser (needs the user logged in)
    and are left NULL when unavailable rather than 0, so an enabled-but-
    logged-out user is not misreported as disabled.

Both tables shell out via the injected utils.CmdRunner and are unit-tested
with MultiMockCmdRunner (no real binaries invoked); coverage 74.3%.
ioreg is parsed as plist (ioreg -a) per the alt_system_info convention.
Apple Silicon only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 2, 2026 01:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds new macOS Touch ID osquery tables that report system-wide sensor/SE posture and per-user Touch ID configuration, backed by bioutil and ioreg.

Changes:

  • Introduces touchid_system_config and touchid_user_config table implementations with parsing/helpers.
  • Adds unit tests and Bazel build targets for the new touchid package.
  • Registers new tables in main.go, documents them in the README, and bumps the project version.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tables/touchid/touchid.go Implements Touch ID table schemas, generators, and command-output parsing.
tables/touchid/touchid_test.go Adds unit tests covering parsers and table row construction via mocked runners.
tables/touchid/BUILD.bazel Adds Bazel targets for the new Go library and tests.
main.go Registers the new Touch ID tables in the osquery plugin list.
README.md Documents the new tables and their behaviors/constraints.
VERSION Bumps version to 1.5.0 to reflect new functionality.
BUILD.bazel Adds the new touchid package to the root build dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tables/touchid/touchid.go Outdated
Comment thread tables/touchid/touchid.go Outdated
Comment thread tables/touchid/touchid_test.go Outdated
- Source secure_enclave from `sysctl -n hw.model` instead of
  `system_profiler SPiBridgeDataType`. Both return the SoC/model identifier
  (e.g. "Mac16,5"), but system_profiler can take seconds and added noticeable
  latency to every query; sysctl is near-instant. Drops the
  chipModelFromSPiBridge parser and its test.

- Check bufio.Scanner.Err() in parseBioutil / parseFingerprintCounts /
  parseLocalUIDs, and enlarge the scan buffer (newLineScanner, 1 MiB cap) so a
  truncated read or an over-long line is treated as "unknown" (empty result)
  rather than silently producing a partial parse.

- Rewrite TestColumns to compare full column-name slices via a columnNames
  helper, so a count/order mismatch is a clean assertion failure instead of an
  index-out-of-range panic.

All three were raised by the Copilot reviewer on macadmins#110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480 robbiet480 requested a review from Copilot June 2, 2026 03:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go Outdated
Follow-up to the previous Copilot round. The "return empty on scanner error"
change conflated parse failure with valid-empty results:

- parseFingerprintCounts now returns (counts, ok). GetUserConfigs sets
  countsKnown from ok, so a truncated/failed parse is treated as "unknown"
  rather than "every user has 0 enrolled fingerprints".

- parseBioutil now returns (fields, ok). On a scanner error the caller skips
  populating the flag columns entirely (leaving them NULL) instead of running
  boolField over an empty map and reporting every flag as "0"/disabled.

- Add nullableBoolField and use it for the per-user touchid_user_config flags
  (unlock / applepay / effective_*), so a flag bioutil did not emit (e.g. on a
  macOS version that omits it) is reported as unknown (NULL) rather than
  silently "0". The zero-fingerprint effective-flag workaround now only fires
  on flags that were actually present.

- Add a sysctlPath constant for the hw.model lookup, matching bioutilPath /
  ioregPath / dsclPath.

Raised by Copilot on macadmins#110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480 robbiet480 requested a review from Copilot June 2, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go
TouchIDUserConfigGenerate gated all four flag columns on `Unlock != ""`, so a
config where touchid_unlock is present but ApplePay/effective_* are absent
(now possible since nullableBoolField returns "" for missing keys) would set
those columns to "" — an invalid value for an IntegerColumn that should be
NULL/omitted. Set each column independently, only when its value is known.

Extracted the row-shaping into userConfigsToRows so the NULL-omission behavior
is unit-testable; added tests for the partial-flags and all-known cases.

Raised by Copilot on macadmins#110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480 robbiet480 requested a review from Copilot June 2, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread tables/touchid/touchid.go
Comment thread tables/touchid/touchid.go Outdated
robbiet480 and others added 2 commits June 2, 2026 10:16
- touchid_compatible was set to "1" based on the *presence* of the
  "Biometrics functionality" field rather than its value, so a hypothetical
  "Biometrics functionality: 0" from bioutil would report compatible=1.
  Derive it from the value via boolField (same field that drives
  touchid_enabled). Added a test for the value=0 case.

- Reword the minHumanUID/maxHumanUID comment so it matches the implemented
  inclusive [501, 60000] range (the old "60000+ are transient" wording was
  ambiguous about whether 60000 itself is included — it is).

Raised by Copilot on macadmins#110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480

Copy link
Copy Markdown
Author

This PR is ready for human review

@grahamgilbert

grahamgilbert commented Jun 3, 2026

Copy link
Copy Markdown
Member

I checked this against a Mac with no Touch ID sensor.

The command behavior looks safe: ioreg -a -r -c AppleBiometricSensor and ioreg -a -r -c AppleMesaAccessory both return empty output on this hardware, and the implementation treats that as touchid_builtin=0 / touchid_sensor_present=0 rather than an error. bioutil -r -s can fail on a no-Touch-ID device, but touchid_system_config swallows that and still returns a row with the Touch ID flags defaulted to 0, which seems reasonable.

For touchid_user_config, failures from bioutil -c -s and launchctl asuser <uid> /usr/bin/bioutil -r are also handled non-fatally. If dscl can enumerate users, the table may return UID-only rows with the Touch ID columns omitted/NULL on a no-Touch-ID device. That is operationally safe, but callers should treat touchid_system_config.touchid_sensor_present = 0 as the authoritative signal that the device has no usable Touch ID sensor.

grahamgilbert and others added 2 commits June 3, 2026 20:00
…-sensor signal

Per @grahamgilbert's testing on a no-Touch-ID Mac: touchid_user_config still
returns a row per local account (UID populated, Touch ID columns NULL) on such
hardware, because user enumeration is independent of the sensor. Document this
in the table's README notes and point callers at
touchid_system_config.touchid_sensor_present = 0 as the authoritative "no
usable Touch ID sensor" signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480

Copy link
Copy Markdown
Author

Thanks for testing this on real no-Touch-ID hardware — that's exactly the case I most wanted validated, and your read matches the intent: empty ioreg output → touchid_builtin=0/touchid_sensor_present=0 (not an error), and the bioutil failures are all swallowed non-fatally with sane defaults.

Your point about touchid_user_config is spot on: on a no-sensor Mac it still emits a row per local account (UID populated, Touch ID columns NULL) because user enumeration via dscl is independent of the hardware. I've added that explicitly to the table's README notes and pointed callers at touchid_system_config.touchid_sensor_present = 0 as the authoritative "no usable Touch ID sensor" signal (50bf673), which is the same guidance you landed on.

@robbiet480

Copy link
Copy Markdown
Author

BTW: As I said at the top of the PR, this is Apple Silicon only. I hope that's okay. I don't have a Intel Mac to test on anyway, but obviously if someone wanted to add support for Intel Macs in the future, that would be great.

@robbiet480

Copy link
Copy Markdown
Author

@grahamgilbert mind approving the workflows to make sure they all pass?

@grahamgilbert

Copy link
Copy Markdown
Member

Your point about touchid_user_config is spot on: on a no-sensor Mac it still emits a row per local account (UID populated, Touch ID columns NULL) because user enumeration via dscl is independent of the hardware. I've added that explicitly to the table's README notes and pointed callers at touchid_system_config.touchid_sensor_present = 0 as the authoritative "no usable Touch ID sensor" signal (50bf673), which is the same guidance you landed on.

No, this should be explicitly handled in the code. This isn’t something that should be in the readme.

Per Graham's review: a Mac with no usable Touch ID sensor must be handled
in code, not punted to callers via the README.

touchid_user_config previously emitted a row per local account (UID
populated, every Touch ID column NULL) on a keyboard-less Mac mini/Studio,
because user enumeration via dscl is independent of the hardware. Callers
then had to filter on touchid_system_config.touchid_sensor_present = 0
themselves.

Now the table gates on sensor presence and returns no rows at all when no
usable sensor exists. Extracted the built-in-OR-accessory detection into a
shared SensorPresent helper so touchid_system_config and touchid_user_config
agree on what "has a sensor" means; GetUserConfigs takes a sensorPresent
bool and short-circuits to no rows when false; TouchIDUserConfigGenerate
computes it via SensorPresent.

Tests: TestGetUserConfigs_NoSensorReturnsNoRows and the constraint variant
assert no rows on a sensor-less Mac (both with and without a WHERE uid =).
README updated to document the no-rows behavior instead of caller-side
filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robbiet480

Copy link
Copy Markdown
Author

No, this should be explicitly handled in the code. This isn't something that should be in the readme.

You're right — moved it into the code (9f3cee0).

touchid_user_config now returns no rows at all on a Mac with no usable Touch ID sensor, instead of a NULL-filled row per local account that callers had to filter out themselves. Specifics:

  • Extracted the built-in-OR-accessory detection into a shared SensorPresent helper, so touchid_system_config and touchid_user_config agree on what "has a sensor" means (no logic drift).
  • GetUserConfigs takes a sensorPresent bool and short-circuits to no rows when it's false — before any dscl/bioutil work — so the gate holds even with an explicit WHERE uid = constraint.
  • TouchIDUserConfigGenerate computes presence via SensorPresent and passes it through.

New tests cover the no-sensor case both with and without a uid constraint (TestGetUserConfigs_NoSensorReturnsNoRows + the constraint variant); full suite + golangci-lint are green. README updated to document the no-rows behavior rather than the old caller-side-filtering guidance.

@robbiet480

robbiet480 commented Jun 10, 2026

Copy link
Copy Markdown
Author

This one is ready to review and merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants