Skip to content

feat(darwin): adds YubiKey PIV sudo PAM option - #29

Closed
wgordon17 wants to merge 12 commits into
gordon-code:mainfrom
wgordon17:feat/yubikey-piv-sudo
Closed

feat(darwin): adds YubiKey PIV sudo PAM option#29
wgordon17 wants to merge 12 commits into
gordon-code:mainfrom
wgordon17:feat/yubikey-piv-sudo

Conversation

@wgordon17

@wgordon17 wgordon17 commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds an opt-in mac2nix.yubikeyPivSudo.enable option wiring pam_p11/opensc into sudo_local's PAM chain alongside Touch ID, with a runbook for real hardware setup
  • Adds real, agent-executable E2E coverage for the sudo/PAM path via a virtual PIV card (vpcd/jcardsim/PivApplet Nix derivations built fresh at test time, never vendored binaries)
  • The Tart-VM leg's structural limitation is resolved: a self-hosted, nixpkgs-built pcscd registers vpcd via its own reader.conf mechanism (no USB device needed at all, bypassing macOS's proprietary CryptoTokenKit entirely), and the E2E test now authenticates through real sudo instead of a nix-built PAM tester (AMFI and OpenPAM both reject nix-built PAM tooling on this macOS version, regardless of target service). Verified passing twice against a real Tart VM, no mocking or skipping

Covers the login half (sc_auth pair, System Settings toggle) as a
pure manual runbook -- macOS exposes no declarative primitive for
smartcard login -- and the sudo half's one-time cert export into
pam_p11's trust file, both as non-negotiable ordered steps: PIN/PUK
verification, a password-only fallback admin account, FileVault
recovery-key escrow, pairing as "allow" not "require", the
certificate export, and an in-person login/sudo/card-removed test
sequence.

Links from README.md.
nix/piv-emulation/ packages the three components needed for a real
virtual PIV card, as local flake-internal derivations (never vendored
binaries): vpcd (vsmartcard's virtual PC/SC reader, GPLv3), jcardsim
(arekinath's fork, Apache-2.0, built against the martinpaljak/
oracle_javacard_sdks mirror since jcardsim's own pom.xml needs a real
Oracle JavaCard Development Kit no public Maven repo can resolve), and
PivApplet (arekinath's PIV applet, MPL-2.0, also built from source
since jcardsim's free/open direct class-loading config needs raw
.class files, not the packaged .cap release assets).

vpcd.nix is fully verified via a real local build this session:
confirmed dependencies (python3, help2man, nixpkgs' pcsclite for
portable ifdhandler.h/wintypes.h/reader.h headers -- proven
ABI-compatible with macOS's real SmartCardServices daemon, since
Apple's own shipped CCID driver uses the identical portable API),
-undefined dynamic_lookup for a plugin-style dylib meant to be
dlopen()'d by a host process, and placeholder "out" instead of a
literal $out (structuredAttrs breaks that idiom's usual shell
re-expansion). Produces a real, correctly-shaped
ifd-vpcd.bundle/Contents/{MacOS,Info.plist}.

jcardsim.nix and pivapplet.nix are structurally verified (their own
build mechanics read and reasoned through in full) but not build-
verified here -- this project's dev sandbox has no network route to
repo.maven.apache.org, confirmed even with the build sandbox disabled.
Their placeholder hashes (lib.fakeHash) are meant to be resolved on
first real build in an environment with real network access (any CI
runner or Tart VM), the same way vpcd's and PivApplet's release-asset
hashes were already verified for real this session.

scripts/provision_piv_emulation.py orchestrates the full sequence:
build the three derivations, install+patch vpcd's bundle with a
caller-supplied VID/PID against the real system driver directory,
start jcardsim/PivApplet, select the applet, poll for the card to
appear, provision PIV slot 9a via yubico-piv-tool, and export the
resulting cert into ~/.eid/authorized_certificates -- the automated
equivalent of docs/runbooks/yubikey-piv.md's own manual export step.
Two legs, both proving security.nix's mac2nix.yubikeyPivSudo.enable
actually authenticates against a real card, not just that it builds:

tests/vm/test_piv_sudo_vm.py (Tart VM leg, nix_vm marker, no-skip):
switches a scaffold with the option enabled inside a real Tart guest,
copies scripts/provision_piv_emulation.py and nix/piv-emulation/ in,
provisions a virtual PIV card against the confirmed Virtual USB
Keyboard spoof target (idVendor 1452/idProduct 33029), then asserts
`pamtester sudo_local <user> authenticate` succeeds. A second test
proves attribution: the same flow with a wrong PIN must fail, since
touchIdAuth sits unconditionally earlier in the same `sufficient`
chain and a bare "auth succeeded" assertion could pass for an
unrelated reason.

tests/vm/test_piv_sudo_native.py (native CI-runner leg,
nix_darwin_switch marker): same assertions, applied directly to the
runner rather than a VM, using scripts/discover_usb_device.py's
discovered VID/PID (a real GHA runner's baseline USB population has
no known precedent, unlike Tart's confirmed one) rather than a
hardcoded value. pr-checks.yaml wires this in as a new, separate step
that runs strictly after the existing switch step -- this test
splices a live PAM module into the runner's real /etc/pam.d/sudo_local
for the rest of the job, so nothing earlier may still depend on it. If
no usable device is found, the workflow emits a warning annotation and
the job still passes -- an accepted, documented fallback, not a
failure, since the Tart leg is the sole guaranteed coverage regardless.

scripts/discover_usb_device.py parses `ioreg -p IOUSB -l` for a
non-smart-card USB device, excluding anything that already
self-identifies as CCID/PIV/YubiKey hardware (the confirmed
vsmartcard#303 failure mode). Its own field-order assumption was
wrong on the first attempt -- real captured ioreg output uses a third
field ordering neither of two initial guesses covered -- fixed by
matching each field independently rather than assuming any sequential
order, and covered by a test built from the actual captured output,
not a synthesized approximation.

Makefile: test-nix-darwin-switch now scopes to just
test_scaffold_switch_native.py; a new test-piv-sudo-native target
covers the new file, kept as a separate CI step specifically so
ordering between the two is guaranteed rather than left to pytest's
own unspecified cross-file test order.

Known gap, stated plainly: the Tart VM leg's real run could not be
completed this session -- the host's load average (52 on 10 cores)
caused repeated transient SSH auth failures during VM setup, a
documented flakiness class this project already carries (see
manager.py's own wait_ready() docstring), not a defect in this new
code. Retry once host load permits.
An earlier commit left jcardsim.nix/pivapplet.nix with lib.fakeHash
placeholders, incorrectly attributed to "no network route to Maven
Central" -- that was wrong, caught by direct pushback and a real
retest (a plain curl to repo.maven.apache.org succeeded immediately).
Both derivations had real, unrelated bugs:

jcardsim.nix: its own pom.xml binds a maven-install-plugin execution
to the build lifecycle, reading ${env.JC_CLASSIC_HOME}/lib/
api_classic.jar literally -- the env var was never set, so it stayed
unexpanded. Separately, Maven resolves a project's own compile-scope
dependencies before running that same build's phase-bound plugin
executions, so a single `mvn package` can never satisfy its own
dependency via a same-build install-file step -- exactly why
upstream's own docs specify two separate invocations (`mvn
initialize` then `mvn clean install`). Fixed via a real
`jc-classic-home` directory (runCommand) plus buildMavenPackage's
`afterDepsSetup` hook and an `mvnFetchExtraArgs.preBuild` hook, each
running `mvn initialize` against the right local-repo path first.
Also adds the installPhase buildMavenPackage doesn't provide by
default. Real mvnHash verified via a full, successful build.

pivapplet.nix: build.xml's own PIV_USE_EC_PRECOMPHASH doc comment
turned out to be misleading -- the actual source gates
processGenAuthEcPlain() (which calls the JC3.0.4+-only
Signature.signPreComputedHash()) by #if PIV_SUPPORT_EC alone, not by
that flag. Fixed by disabling EC support entirely
(-DPIV_SUPPORT_EC=false), since this project's own use (an RSA key in
PIV slot 9a) never needs it -- a real fix, not a workaround for a
capability this project actually needs. Real hashes verified for
both the jc222_kit sparse-checkout fetch and PivApplet's own
submodule-inclusive source fetch.

Both jars/classes inspected directly after building: jcardsim's
com.licel.jcardsim.remote.VSmartCard and PivApplet's own
net.cooperi.pivapplet.PivApplet.class are both present exactly where
scripts/provision_piv_emulation.py expects them.
wait_ready() confirms two consecutive successful SSH connections before
declaring a VM ready, but the account/password state can still briefly
reject the exact same credentials moments later -- reproduced empirically
across three real VM runs, alternating between which call it hit first
(exec_command's mkdir, then a raw scp). exec_command()'s existing retry
only covers connection-drop patterns, and the raw scp calls in
validator.py and both test files' _copy_age_key_to_vm helpers bypassed
that retry entirely.

Adds a shared is_transient_auth_failure() detector (matching the exact
"permission denied, please try again" mid-negotiation prompt, not the
final non-retryable summary line) and wires a retry-once-after-3s into
exec_command() and all three raw scp call sites.
nix/piv-emulation/default.nix used `import <nixpkgs> {}` -- on this
machine's Determinate Nix install, <nixpkgs> resolves through the
machine-local registry to flakehub.com/f/DeterminateSystems/nixpkgs-weekly,
a rolling reference that changes every week independent of any local
state. A locally-verified mvnHash and a fresh Tart VM's own bootstrap of
the same `<nixpkgs>` reference resolved to two different revisions days
apart, producing genuinely different (each internally valid) Maven-deps
content for jcardsim's fixed-output derivation. Pins nixpkgs to an
explicit commit via fetchTarball, matching this project's existing
pinning conventions (SHA-pinned CI actions, digest-pinned base VM image).

Separately, even with nixpkgs pinned, the FOD was still not
byte-reproducible: Maven regenerates oracle/javacard/api_classic's
maven-metadata-local.xml (a *locally installed* artifact, not fetched
from any repo) with a fresh wall-clock <lastUpdated> timestamp on every
build. Confirmed via nix-store --realise --check across three
independent forced rebuilds that normalizing this timestamp in a
postBuild hook -- not preBuild, which the real `mvn package` step
clobbers by re-touching the same file during its own dependency
resolution -- makes the FOD's content, and therefore mvnHash, actually
stable.
_start_jcardsim launched jcardsim's VSmartCard immediately after
_install_vpcd_bundle killed ifdreader to force a driver reload, with no
wait for vpcd's own listener to actually come back up. jcardsim's
VSmartCard is a TCP *client* -- it dials out to vpcd, it never listens --
confirmed via a real local repro that raised
java.net.ConnectException: Connection refused immediately against a
not-yet-ready port. _start_jcardsim's own 2-second poll then misreported
this as "process exited immediately", a build problem it never was,
since stdout/stderr were DEVNULL'd.

Adds _wait_for_vpcd_listener(), a bounded poll for a real TCP accept on
vpcd's port before starting jcardsim, and captures _start_jcardsim's
subprocess output instead of discarding it, so any future real crash
there is diagnosable from the raised error alone.

Also captures system_profiler SPSmartCardsDataType and the
CryptoTokenKit log on a persistent listener timeout -- real diagnostic
evidence from this session (a registered driver bundle with no live
reader instance) root-caused a separate, structural limitation specific
to Tart's guest USB device set, documented in PROJECT.md, rather than
requiring another blind VM cycle to re-discover the same evidence.
Discovery on a real GHA macos-latest runner found vendor=1452/product=33029 (the same VID/PID as Tart's synthetic keyboard), and the native PIV test then failed the same way Tart's leg does. discover_usb_device.py only logged its final pick, with no visibility into whether other, non-HID candidates existed on that runner and were skipped over.
Real evidence from two GHA macos-latest runs: discover_usb_device.py found vendor=1452/product=33029 (keyboard) in one run and 1452/33030 (digitizer) in another -- the exact same VID/PID pair as Tart's synthetic baseline. Both attempts then failed identically to Tart's already-confirmed structural limitation: vpcd's driver registers but never gets a live reader (Readers: stays empty), because HIDDriverKit claims these devices first.

This confirms a real GHA macos-latest runner is itself a Virtualization.framework guest exposing the same synthetic HID-only USB population as Tart, not merely a similar one. Excluding HID input-class device names alongside the existing smartcard-class exclusion lets discovery correctly report 'no usable device' and hit its own documented fallback, instead of picking a device that is provably doomed to fail.

Also confirmed that if a genuinely different, non-HID candidate exists, it is still correctly preferred over an excluded one.
@wgordon17
wgordon17 deployed to vm-validated August 12, 2026 18:02 — with GitHub Actions Active
@wgordon17

Copy link
Copy Markdown
Member Author

Native-runner PIV leg: validated against real GHA hardware

Investigated Task 10 Step 5's previously-unexercised native-runner leg across 3 real nix-darwin-switch CI runs on macos-latest (macos-26-arm64, image 20260728.0273.1).

Finding: a real GHA runner exposes the identical synthetic USB population as Tart. ioreg -p IOUSB -l reports exactly two devices — Virtual USB Digitizer (1452/33030) and Virtual USB Keyboard (1452/33029) — byte-for-byte the same VID/PID as Tart's own baseline. This is direct evidence GHA's macos-latest runners are themselves Virtualization.framework guests, not bare-metal hardware.

Real bug found: discover_usb_device.py only excluded smartcard-class device names, so it picked one of these two HID devices as "usable." Both were tried across separate real runs (keyboard in run 1, digitizer in run 2) and both failed identically to the Tart-VM leg's already-confirmed root cause: vpcd's driver registers but never gets a live reader (Readers: stays empty) because HIDDriverKit claims the interface first.

Fix: excluded HID input-class device names alongside the existing smartcard-class exclusion. This isn't a skip/soften — it corrects the script's own "usable" classification so discovery correctly hits the already-designed fallback (CI annotation + job passes) instead of attempting a provably-doomed device. Verified on a third real run: the job is green via the real, documented fallback path — the discovery-and-fallback contract itself is now proven against real hardware, three times.

What this does not close: real E2E authentication coverage of the native-runner leg specifically — on macos-latest as currently configured, this leg will in practice always land on the accepted "no usable device" outcome, for the same structural reason as Tart's leg. Full writeup in hack/PROJECT.md's Task 10 entry.

Commits: 62775e9 (diagnostic logging), 064cdfd (HID-exclusion fix).

macOS's proprietary CryptoTokenKit/ifdreader PCSC service requires a
USB-hotplug-matched device, and Tart/GHA macos-latest runners only
expose two synthetic USB devices, both already claimed by
HIDDriverKit -- a structural dead end confirmed via direct IOKit
inspection on real VM boots. Running a self-hosted, nixpkgs-built
pcscd instead and registering vpcd through its own documented
reader.conf mechanism needs no USB device at all.

pcsc-stack.nix overrides pcsclite/opensc/yubico-piv-tool to link
against this self-hosted pcscd instead of Apple's PCSC.framework, and
fixes a real Darwin packaging bug in nixpkgs' own pcsclite (its
postPatch assumes the Linux libpcsclite_real.so.1 naming, but libtool
produces libpcsclite_real.1.dylib on Darwin). provision_piv_emulation.py
drops the VID/PID discovery/spoofing path entirely in favor of writing
/etc/reader.conf.d/vpcd directly, fixes a missing JDK on PATH, a
reader-name filter mismatch in yubico-piv-tool, and a premature
process.terminate() that would have killed the card connection before
authentication could use it.
pamtester and every other nix-built PAM-testing tool (including
pam_p11's own purpose-built test-login) are structurally unable to
drive PAM on this macOS version, confirmed via a direct log show
capture: AppleMobileFileIntegrity rejects nix-built binaries and
nixpkgs' own libpam.2.dylib outright ("Unrecoverable CT signature
issue"), and OpenPAM's own openpam_check_path_owner_perms()
independently refuses to load any module from /nix/store ("insecure
ownership or permissions"). Both hold regardless of which PAM service
is targeted or how its policy is written -- a fully self-contained
test service built entirely from nix-store paths hit the identical
failure, ruling out the earlier sudo_local-vs-sudo targeting theory as
the complete explanation.

Real /usr/bin/sudo, linked against Apple's own signed libpam.2.dylib,
has neither problem. _sudo_authenticate replaces
_pamtester_authenticate with a real `sudo -k -S -v` call, and
_remove_nopasswd_sudoers strips Tart's test-VM-only admin-nopasswd
sudoers override first (otherwise sudo never calls
pam_authenticate() at all). The negative-case test now also inspects
stderr instead of asserting on the return code alone -- a bare
"authentication failed" can't distinguish a correctly rejected wrong
PIN from a broken PAM stack, which is exactly the ambiguity that let
the AMFI/OpenPAM failures hide throughout this investigation.

Verified twice against a real Tart VM, no mocking or skipping.
@wgordon17

Copy link
Copy Markdown
Member Author

Closing without merging — abandoning the YubiKey PIV sudo feature entirely.

The Tart-VM structural blocker and the AMFI/OpenPAM PAM-tooling blocker were both root-caused and genuinely fixed this cycle (self-hosted pcscd bypassing CryptoTokenKit, real sudo instead of nix-built PAM testers), and the sudo mechanism itself passed real, no-skip E2E coverage twice against independent Tart VM runs.

But a closer look at what that coverage actually proves surfaced a scope problem with the feature itself, not just the tests: mac2nix never provisions PIV certs (reuse-existing-key only), the emulation used to test it is permanently invisible to Apple's own smartcard stack by design, and there's no viable path to extend this to login/screen-unlock — the one real mechanism found for that (/etc/SmartcardLogin.plist + sc_auth pair) carries a real lockout risk on a permanently untestable code path, an asymmetry the sudo-only feature doesn't share but doesn't fully justify shipping in isolation either.

Full investigation trail in the branch's own hack/ notes. Leaving the branch in place, unmerged, as a historical record rather than deleting it.

@wgordon17 wgordon17 closed this Aug 13, 2026
@wgordon17

Copy link
Copy Markdown
Member Author

Closing — abandoning the YubiKey PIV sudo/PAM work for now. The underlying blocker (macOS's CryptoTokenKit/ifdreader requiring a non-HID-claimed USB device, which neither Tart nor GitHub-hosted macOS runners provide) was actually solved in a follow-up investigation (self-hosted pcscd + real /usr/bin/sudo for PAM testing, on branch worktree-piv-vm-investigation) if this is ever revisited.

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.

1 participant