From 6ae960dc78a5777d9b1718c8ac0ae7100393bcd7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 15:24:29 -0400 Subject: [PATCH 01/12] feat(darwin): adds YubiKey PIV sudo PAM option --- .../scaffold/modules/darwin/default.nix | 1 + .../scaffold/modules/darwin/security.nix | 20 +++ tests/generators/test_security_nix.py | 131 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/mac2nix/templates/scaffold/modules/darwin/security.nix create mode 100644 tests/generators/test_security_nix.py diff --git a/src/mac2nix/templates/scaffold/modules/darwin/default.nix b/src/mac2nix/templates/scaffold/modules/darwin/default.nix index 29bccb3..5b81b1a 100644 --- a/src/mac2nix/templates/scaffold/modules/darwin/default.nix +++ b/src/mac2nix/templates/scaffold/modules/darwin/default.nix @@ -3,5 +3,6 @@ { imports = [ ./homebrew.nix + ./security.nix ]; } diff --git a/src/mac2nix/templates/scaffold/modules/darwin/security.nix b/src/mac2nix/templates/scaffold/modules/darwin/security.nix new file mode 100644 index 0000000..6b812e1 --- /dev/null +++ b/src/mac2nix/templates/scaffold/modules/darwin/security.nix @@ -0,0 +1,20 @@ +{ config, lib, pkgs, ... }: +{ + options.mac2nix.yubikeyPivSudo.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Require or allow YubiKey PIV smartcard authentication for sudo, in addition to Touch ID."; + }; + + config = { + security.pam.services.sudo_local.touchIdAuth = lib.mkDefault true; + environment.systemPackages = lib.mkIf config.mac2nix.yubikeyPivSudo.enable [ pkgs.pam_p11 pkgs.opensc ]; + # pam_p11 does a simple challenge-response against a pre-registered + # public key/cert (no CA-chain/CRL checking) -- see docs/runbooks/yubikey-piv.md + # for the one-time cert export this requires. sufficient, not required: a + # lost/unavailable card must never lock sudo behind the card alone. + security.pam.services.sudo_local.text = lib.mkIf config.mac2nix.yubikeyPivSudo.enable ( + lib.mkAfter "auth sufficient ${pkgs.pam_p11}/lib/security/pam_p11.so ${pkgs.opensc}/lib/opensc-pkcs11.so" + ); + }; +} diff --git a/tests/generators/test_security_nix.py b/tests/generators/test_security_nix.py new file mode 100644 index 0000000..e71bb3b --- /dev/null +++ b/tests/generators/test_security_nix.py @@ -0,0 +1,131 @@ +"""Parse/eval/build checks for `modules/darwin/security.nix`'s YubiKey PIV sudo option. + +`test_options_structure` (marker `nix`) only checks the module parses — +`--parse` never evaluates the module system, so it can't prove the option +actually renders the PAM line correctly. `test_evaluation`/ +`test_build_with_option_enabled` (marker `nix_build`, never skipped) close +that gap with a real `nix eval`/`nix build` against a freshly-scaffolded +flake, mirroring `test_scaffold_integration.py`'s own real-build pattern. +""" + +from __future__ import annotations + +import getpass +import shutil +import subprocess +from pathlib import Path + +import pytest + +from mac2nix.generators.scaffold import add_host, init_framework +from tests._scaffold_helpers import _nix_extra_access_tokens_args, _redirect_age_keys + +_HOSTNAME_DISABLED = "mac2nix-piv-disabled" +_HOSTNAME_ENABLED = "mac2nix-piv-enabled" + + +@pytest.fixture +def require_nix_instantiate() -> None: + if shutil.which("nix-instantiate") is None: + pytest.skip("nix-instantiate not on PATH") + + +@pytest.mark.nix +def test_options_structure(require_nix_instantiate: None) -> None: + """The rendered security.nix module is syntactically valid Nix.""" + module_path = Path(__file__).parents[2] / "src/mac2nix/templates/scaffold/modules/darwin/security.nix" + result = subprocess.run( # noqa: S603 + ["nix-instantiate", "--parse", str(module_path)], # noqa: S607 + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"nix-instantiate --parse failed:\n{result.stderr}" + + +def _enable_yubikey_piv_sudo(output_dir: Path, hostname: str) -> None: + """Patch a registered host's configuration.nix to set `mac2nix.yubikeyPivSudo.enable = true;`. + + Mirrors how a real user enables the option — hand-editing their host's + own configuration.nix — rather than inventing a test-only override + mechanism. + """ + config_path = output_dir / "hosts" / "darwin" / hostname / "configuration.nix" + content = config_path.read_text() + marker = "system.stateVersion = 7;" + replacement = f"{marker}\n mac2nix.yubikeyPivSudo.enable = true;" + config_path.write_text(content.replace(marker, replacement)) + + +@pytest.mark.nix_build +def test_evaluation(tmp_path: Path) -> None: + """The PAM line is present when enabled and absent when disabled.""" + output_dir = tmp_path / "mac2nix-scaffold" + # Two distinct usernames -- generate_age_key() refuses to reuse an + # existing key across hosts, and this test never applies a real switch, + # so neither username needs to match a real system account. + username_disabled = f"{getpass.getuser()}-piv-disabled" + username_enabled = f"{getpass.getuser()}-piv-enabled" + token_args = _nix_extra_access_tokens_args() + + init_framework(output_dir) + with _redirect_age_keys(tmp_path / "age-keys"): + add_host(output_dir, _HOSTNAME_DISABLED, username_disabled, confirm_backup=lambda _fp: True) + add_host(output_dir, _HOSTNAME_ENABLED, username_enabled, confirm_backup=lambda _fp: True) + _enable_yubikey_piv_sudo(output_dir, _HOSTNAME_ENABLED) + + lock_result = subprocess.run( # noqa: S603 + ["nix", "flake", "lock", *token_args], # noqa: S607 + cwd=output_dir, + capture_output=True, + text=True, + check=False, + ) + assert lock_result.returncode == 0, f"nix flake lock failed:\n{lock_result.stderr}" + + for hostname, should_contain in ((_HOSTNAME_DISABLED, False), (_HOSTNAME_ENABLED, True)): + attr = f".#darwinConfigurations.{hostname}.config.security.pam.services.sudo_local.text" + eval_cmd = ["nix", "eval", "--raw", attr, *token_args] + eval_result = subprocess.run( # noqa: S603 + eval_cmd, + cwd=output_dir, + capture_output=True, + text=True, + check=False, + ) + assert eval_result.returncode == 0, f"nix eval failed for {hostname}:\n{eval_result.stderr}" + contains_pam_line = "pam_p11.so" in eval_result.stdout and "opensc-pkcs11.so" in eval_result.stdout + assert contains_pam_line == should_contain, ( + f"{hostname}: expected pam_p11 line present={should_contain}, got stdout:\n{eval_result.stdout}" + ) + + +@pytest.mark.nix_build +def test_build_with_option_enabled(tmp_path: Path) -> None: + """A full darwin system build succeeds with the option enabled, exercising pam_p11/opensc for real.""" + output_dir = tmp_path / "mac2nix-scaffold" + username = getpass.getuser() + token_args = _nix_extra_access_tokens_args() + + init_framework(output_dir) + with _redirect_age_keys(tmp_path / "age-keys"): + add_host(output_dir, _HOSTNAME_ENABLED, username, confirm_backup=lambda _fp: True) + _enable_yubikey_piv_sudo(output_dir, _HOSTNAME_ENABLED) + + lock_result = subprocess.run( # noqa: S603 + ["nix", "flake", "lock", *token_args], # noqa: S607 + cwd=output_dir, + capture_output=True, + text=True, + check=False, + ) + assert lock_result.returncode == 0, f"nix flake lock failed:\n{lock_result.stderr}" + + build_result = subprocess.run( # noqa: S603 + ["nix", "build", f".#darwinConfigurations.{_HOSTNAME_ENABLED}.system", "--no-link", *token_args], # noqa: S607 + cwd=output_dir, + capture_output=True, + text=True, + check=False, + ) + assert build_result.returncode == 0, f"nix build failed:\n{build_result.stderr}" From 49713dd9250ab249ff9aed2a7fb910c954ad6626 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 15:26:19 -0400 Subject: [PATCH 02/12] docs(darwin): adds YubiKey PIV login and sudo runbook 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. --- README.md | 4 + docs/runbooks/yubikey-piv.md | 170 +++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 docs/runbooks/yubikey-piv.md diff --git a/README.md b/README.md index a2d6384..d48ace9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ uv sync uv run mac2nix --help ``` +## Runbooks + +- [YubiKey PIV login + sudo](docs/runbooks/yubikey-piv.md) + ## License MIT diff --git a/docs/runbooks/yubikey-piv.md b/docs/runbooks/yubikey-piv.md new file mode 100644 index 0000000..40635fa --- /dev/null +++ b/docs/runbooks/yubikey-piv.md @@ -0,0 +1,170 @@ +# YubiKey PIV login + sudo runbook + +This runbook covers enabling an *existing* YubiKey's PIV identity (already +provisioned with certificates — this is not a certificate-generation guide) +for macOS login-screen authentication and `sudo`. + +**Scope split, and why it matters for how you read this document:** + +- **Login** (`sc_auth pair`, the System Settings smartcard toggle) has no + nix-darwin declarative primitive at all — macOS exposes no config file or + option for this, only imperative local machine state. Everything below for + login is manual, one-time, per-machine setup with no automated test + coverage — you are the verification. +- **Sudo** is just another PAM line, and gets a real nix-darwin option, + `mac2nix.yubikeyPivSudo.enable` (`modules/darwin/security.nix`). This half + *does* have real, automated end-to-end test coverage — a virtual PIV card + is used in this project's own test suite to prove the PAM wiring actually + authenticates, not merely that it builds (see + `tests/vm/test_piv_sudo_vm.py`/`tests/vm/test_piv_sudo_native.py`). That + coverage tests the option's *mechanism*; it does not replace the + in-person verification in Step 7 below, which is about *your* physical + key and *your* machine. + +Every step below is a non-negotiable prerequisite or verification, not +optional advice — skipping any of them trades a real safety margin for +convenience. + +## 1. Verify the PIV PIN and PUK are known, non-default values + +A locked PIN can be reset with the PUK. A locked PUK requires wiping the +PIV applet entirely — destroying the existing certificates this runbook +assumes you already have and are not regenerating. + +```sh +ykman piv info +``` + +Confirm you can actually authenticate with the PIN before proceeding — if +you're not certain, verify it now rather than discovering it's wrong at +Step 4 or 5, where a wrong PIN starts consuming retry attempts. + +## 2. Set up a password-only fallback admin account + +Create a second local administrator account, explicitly excluded from any +smartcard enforcement, **before** enabling anything below. This is the +actual recovery path if the YubiKey is lost, damaged, or misbehaves — not +"recovery mode," not a theoretical safety net. + +Document exactly where this account's credentials live (a named entry in +your password manager). If you can't say precisely where they are, this +step isn't done. + +## 3. Escrow and verify the FileVault recovery key + +Escrow the FileVault personal recovery key (the 24-character key, from +`fdesetup` or System Settings → Privacy & Security → FileVault) in your +password manager, and **confirm it's actually retrievable** — not just +assumed present. + +This is a hard prerequisite, not a nice-to-have: on Apple Silicon, pre-boot +disk unlock only recognizes whichever smart card was *last used on that +specific machine*. If this same physical YubiKey is ever used on a +different Mac, this machine's pre-boot unlock could end up depending on a +card state that's since changed. The FileVault recovery key is the only +fallback that doesn't depend on the card's state, on sops-nix, or on +anything else this machine's own disk needs to be unlocked to reach. + +## 4. Pair the card and enable smartcard login as "allow" + +```sh +sc_auth identities +sc_auth pair -u -h +``` + +Then enable smartcard login in System Settings → Users & Groups, as +**"allow"** — never **"require"**. With only one physical key and no +backup-issuance path, "require" turns a lost key into a lockout with no +self-service recovery. + +## 5. Export the PIV certificate into `pam_p11`'s trust file + +`pam_p11` authenticates via a simple challenge-response against a +pre-registered public key or certificate — not full CA-chain/CRL/OCSP +validation. The existing PIV certificate has to be registered once for +this to work. + +As a fast, no-YubiKey-needed sanity check before touching the physical key +at all, confirm the package resolves: + +```sh +nix build nixpkgs#pam_p11 +find "$(nix build nixpkgs#pam_p11 --no-link --print-out-paths)" -iname 'pam_p11.so' +``` + +`${pkgs.opensc}` is Nix interpolation syntax — it only means something +inside a `.nix` file, not at a shell prompt. At this point in the runbook, +`opensc` isn't in `environment.systemPackages` yet (it's gated behind +`mac2nix.yubikeyPivSudo.enable`, not yet turned on), so `pkcs11-tool` isn't +on `PATH` either. Get both onto `PATH` for this shell session and resolve +`opensc`'s real PKCS#11 module path: + +```sh +nix shell nixpkgs#opensc nixpkgs#pam_p11 +OPENSC_PKCS11="$(nix build nixpkgs#opensc --no-link --print-out-paths)/lib/opensc-pkcs11.so" +``` + +Now export the existing certificate (this reads the card, it does not +generate anything new on it): + +```sh +pkcs11-tool --list-objects --type cert --module "$OPENSC_PKCS11" +# note the certificate's id from the output above +pkcs11-tool --read-object --type cert --id --module "$OPENSC_PKCS11" --output-file /tmp/piv-cert.cer +mkdir -p ~/.eid && chmod 0755 ~/.eid +openssl x509 -inform DER -in /tmp/piv-cert.cer -outform PEM >> ~/.eid/authorized_certificates +chmod 0644 ~/.eid/authorized_certificates +``` + +**Fallback, only if a future nixpkgs revision ever stops shipping a working +`pam_p11` for macOS** (not expected — it is currently Hydra-tracked and +binary-cached for aarch64-darwin): edit `/etc/pam.d/sudo_local` directly +and imperatively instead, adding this line above the Touch ID line: + +``` +auth sufficient +``` + +**This fallback is not permanent and does not survive a rebuild.** +`security.nix`'s `touchIdAuth = lib.mkDefault true` line is unconditional, +which puts `/etc/pam.d/sudo_local` under nix-darwin's `environment.etc` +management the moment `security.nix` is imported — regardless of whether +the YubiKey option is ever turned on. Any manual edit to that file is +silently overwritten (back to Touch-ID-only) the next time you run +`darwin-rebuild switch`, not just after a macOS upgrade. Treat the manual +fallback as something you must re-apply after every switch for as long as +this stopgap is needed — it is not a stable substitute for the declarative +option. + +## 6. Enable the option and switch + +In the host's `configuration.nix`: + +```nix +mac2nix.yubikeyPivSudo.enable = true; +``` + +```sh +darwin-rebuild switch --flake .# +``` + +(Skip this step if you used the manual fallback in Step 5 instead.) + +## 7. Test in this exact order, in person + +**Never over SSH-only access** — if any of these fail, you need to be at +the physical machine. + +1. Full logout/login cycle, authenticating with the card. +2. `sudo` at a terminal, authenticating with the card. +3. Reboot with the card physically removed. Confirm password login and + password `sudo` both still work. + +Do not consider this runbook followed until all three pass, in this order. + +## 8. If the key is lost + +Use the fallback admin account from Step 2 to log in and administer the +machine. `sc_auth pair`'s state is local to this machine only — there is +no server-side identity to revoke, and no remote action is needed. Once +you have a replacement key, repeat Steps 4-5 for it. From 586b77c5d8863e94e7cd16c53257c04bd0e3a3ac Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 18:39:37 -0400 Subject: [PATCH 03/12] feat(vm): adds vpcd/jcardsim/PivApplet Nix derivations and provisioning 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. --- nix/piv-emulation/default.nix | 11 ++ nix/piv-emulation/jcardsim.nix | 79 +++++++++ nix/piv-emulation/pivapplet.nix | 83 +++++++++ nix/piv-emulation/vpcd.nix | 81 +++++++++ scripts/provision_piv_emulation.py | 235 ++++++++++++++++++++++++++ tests/test_provision_piv_emulation.py | 156 +++++++++++++++++ 6 files changed, 645 insertions(+) create mode 100644 nix/piv-emulation/default.nix create mode 100644 nix/piv-emulation/jcardsim.nix create mode 100644 nix/piv-emulation/pivapplet.nix create mode 100644 nix/piv-emulation/vpcd.nix create mode 100644 scripts/provision_piv_emulation.py create mode 100644 tests/test_provision_piv_emulation.py diff --git a/nix/piv-emulation/default.nix b/nix/piv-emulation/default.nix new file mode 100644 index 0000000..b145d7c --- /dev/null +++ b/nix/piv-emulation/default.nix @@ -0,0 +1,11 @@ +# Local, flake-internal PIV-emulation derivations -- NOT part of the +# generated scaffold output shipped to real hosts (see +# templates/scaffold/flake.nix). Only ever consumed by this project's own +# test suite (tests/vm/test_piv_sudo_vm.py, tests/vm/test_piv_sudo_native.py) +# via scripts/provision_piv_emulation.py. +{ pkgs ? import { } }: +{ + vpcd = pkgs.callPackage ./vpcd.nix { }; + jcardsim = pkgs.callPackage ./jcardsim.nix { }; + pivapplet = pkgs.callPackage ./pivapplet.nix { }; +} diff --git a/nix/piv-emulation/jcardsim.nix b/nix/piv-emulation/jcardsim.nix new file mode 100644 index 0000000..79fb60e --- /dev/null +++ b/nix/piv-emulation/jcardsim.nix @@ -0,0 +1,79 @@ +# Builds arekinath's jcardsim fork (Apache-2.0, confirmed via its pom.xml's +# block -- GitHub's repo-level license detector finds nothing here, +# but the license is real). +# +# jcardsim's own pom.xml declares oracle.javacard:api_classic as a compile +# dependency, installed via `mvn install-file` from a real Oracle JavaCard +# Development Kit -- not resolvable from any public Maven repo, and Oracle's +# license doesn't permit redistributing that SDK freely. This derivation uses +# martinpaljak/oracle_javacard_sdks, the de facto mirror the open-source +# JavaCard community already relies on for exactly this problem (PivApplet's +# own build tooling, ant-javacard, points people at the same mirror) -- +# accepted as a real, documented trade-off, not a silent workaround. +# +# Built with jdk8 explicitly: jcardsim's pom.xml targets java.version 1.7, +# which modern JDKs (9+) refuse to compile for. The separate `integration-test` +# phase (maven-antrun-plugin, needing tools.jar -- removed in JDK 9+) is never +# reached, since buildMavenPackage's default `package` goal stops before that +# lifecycle phase. +{ + lib, + fetchFromGitHub, + fetchurl, + maven, + jdk8, +}: +let + apiClassicJar = fetchurl { + url = "https://raw.githubusercontent.com/martinpaljak/oracle_javacard_sdks/6a75ec0d6913db236d354f154df7dbc9573d976d/jc305u4_kit/lib/api_classic.jar"; + hash = "sha256-xDCNvuGS3D8SUJEhZg0HG0b6vUQjxL+XFH4DnB0WLtg="; + }; + + installApiClassic = '' + mvn -B install:install-file \ + -Dfile=${apiClassicJar} \ + -DgroupId=oracle.javacard \ + -DartifactId=api_classic \ + -Dversion=3.0.5 \ + -Dpackaging=jar \ + -Dmaven.repo.local=$out/.m2 + ''; +in +maven.buildMavenPackage { + pname = "jcardsim"; + version = "3.0.5-SNAPSHOT-mac2nix"; + + src = fetchFromGitHub { + owner = "arekinath"; + repo = "jcardsim"; + rev = "4c766cfb48c43507f9a30a1443e7214d2073a430"; + hash = "sha256-0akp8BAp6QxGuBrDFEg0ED/F98bCkO2WQSocYqWszyI="; + }; + + mvnJdk = jdk8; + mvnGoal = "package"; + doCheck = false; + + # api_classic must already be in the local repo before the dependency + # prefetch's own `mvn package` runs, or that prefetch itself fails outright + # trying (and failing) to resolve it from a real repo. + mvnFetchExtraArgs = { + preBuild = installApiClassic; + }; + # [ASSUMPTION: verify on first real build] lib.fakeHash is a deliberate + # placeholder, not an oversight -- this project's own dev sandbox has no + # network route to repo.maven.apache.org (confirmed: github.com/ + # cache.nixos.org fetches all work fine from here; Maven Central does + # not, even with the build sandbox disabled, so the restriction sits + # below Nix's own sandboxing). Any environment with real network access + # (CI, a Tart VM) will report the correct hash on first build via Nix's + # standard "hash mismatch: got sha256-..." error -- replace this value + # with that real hash once available, the same way vpcd.nix's and + # pivapplet.nix's hashes were already verified for real in this session. + mvnHash = lib.fakeHash; + + meta = { + description = "Pure-Java Card Runtime simulator (arekinath fork, vpcd-enabled)"; + license = "Apache-2.0"; + }; +} diff --git a/nix/piv-emulation/pivapplet.nix b/nix/piv-emulation/pivapplet.nix new file mode 100644 index 0000000..06c3ad7 --- /dev/null +++ b/nix/piv-emulation/pivapplet.nix @@ -0,0 +1,83 @@ +# Builds arekinath/PivApplet (MPL-2.0, confirmed via the +# `Copyright (c) 2017, Alex Wilson` header in PivApplet.java -- no repo-root +# LICENSE file exists, but the per-file header is a real, sufficient grant) +# from source. +# +# PivApplet's GitHub Releases publish pre-built .cap files, but those are +# packaged for installation onto a real card (or a jcardsim configured with +# its commercial GlobalPlatform card-manager module) via GlobalPlatformPro's +# `gp.jar --install`. The free/open-source jcardsim this project uses +# (jcardsim.nix) doesn't carry that card-manager applet -- its own +# jcardsim.cfg loads an applet directly by Java class name +# (com.licel.jcardsim.card.applet.0.Class=net.cooperi.pivapplet.PivApplet), +# which needs PivApplet's raw compiled .class files, not a packaged .cap. +# This is the exact recipe PivApplet's own test setup uses (test/jcardsim.cfg, +# `ant` then `java -cp bin/:...`), not a mac2nix-invented alternative. +# +# Uses JavaCard Classic Development Kit 2.2.2 (a different kit version than +# jcardsim.nix's 3.0.5 -- PivApplet's own README specifies JC_HOME pointing +# at a 2.2.2 kit), from the same martinpaljak/oracle_javacard_sdks mirror +# jcardsim.nix already uses and documents the trade-off for. +{ + lib, + fetchFromGitHub, + stdenv, + jdk8, + ant, +}: +let + jc222Kit = fetchFromGitHub { + owner = "martinpaljak"; + repo = "oracle_javacard_sdks"; + rev = "6a75ec0d6913db236d354f154df7dbc9573d976d"; + hash = lib.fakeHash; + sparseCheckout = [ "jc222_kit" ]; + }; +in +stdenv.mkDerivation { + pname = "pivapplet"; + version = "unstable-2026-08-08"; + + src = fetchFromGitHub { + owner = "arekinath"; + repo = "PivApplet"; + rev = "5cb14a9e8d16e92fbad73dcad86a219a9210554f"; + # ext/ant is a git submodule (martinpaljak/ant-javacard) -- needed to + # build (build.xml's `dist` target runs `` first). + # ext/jpp-1.0.3.jar (the other build-time tool build.xml needs) is a + # plain committed file, not a submodule, so a normal fetch already + # includes it. + fetchSubmodules = true; + hash = lib.fakeHash; + }; + + nativeBuildInputs = [ + jdk8 + ant + ]; + + JC_HOME = "${jc222Kit}/jc222_kit"; + + # build.xml's `dist` target builds ext/ant (ant-javacard) first, then + # preprocesses+compiles+packages the applet -- see build.xml's own + # `dist`/`preprocess` targets. Real network access to Maven Central + # (ant-javacard's own build dependency) is required and not available in + # this project's dev sandbox -- see jcardsim.nix's identical note. + buildPhase = '' + runHook preBuild + ant dist + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r bin/. $out/ + runHook postInstall + ''; + + meta = { + description = "PIV applet for JavaCard (arekinath/PivApplet), built for jcardsim's direct class-loading mode"; + license = "MPL-2.0"; + }; +} diff --git a/nix/piv-emulation/vpcd.nix b/nix/piv-emulation/vpcd.nix new file mode 100644 index 0000000..4ab6641 --- /dev/null +++ b/nix/piv-emulation/vpcd.nix @@ -0,0 +1,81 @@ +# Builds vpcd (frankmorgner/vsmartcard's `virtualsmartcard` component, GPLv3, +# confirmed via its COPYING file) -- the virtual PC/SC reader that lets a +# software-only PIV card emulator (jcardsim.nix + pivapplet.nix) register +# with macOS's own smartcard stack. +# +# Standalone process, invoked independently -- never linked into mac2nix's +# own Python/Nix code -- so this falls under GPLv3's mere-aggregation +# allowance. Do not vendor its compiled output into anything this project +# ships to end users. +# +# Configured to match the real, official `make osx` build target's own +# recipe (virtualsmartcard/MacOSX/Makefile.am): --enable-infoplist plus +# pointing --enable-serialdropdir/--enable-serialconfdir directly at a +# bundle-shaped path under $out, which assembles a real +# ifd-vpcd.bundle/Contents/{MacOS,Info.plist} layout purely through those +# install-path choices -- there is no separate "bundle template" mechanism. +# Uses nixpkgs' own pcsclite for the ifdhandler.h/wintypes.h/reader.h headers +# (pkg-config discoverable, per configure.ac's default libpcsclite=no path) +# rather than Apple's proprietary PCSC.framework via an ambient `xcode-select` +# lookup -- keeps the build hermetic, and is proven ABI-compatible with +# macOS's real SmartCardServices daemon: Apple's own shipped CCID driver +# (ifd-ccid.bundle) is itself built against this exact same portable +# PC/SC IFD-handler API. +# +# The resulting bundle's ifdVendorID/ifdProductID must be patched by the +# provisioning script (scripts/provision_piv_emulation.py) with a +# caller-supplied VID/PID *after* this builds, not baked in here -- the VM +# and native-runner execution contexts use different, discovered-not-assumed +# target devices. +{ + fetchFromGitHub, + stdenv, + autoreconfHook, + pkg-config, + pcsclite, + python3, + help2man, +}: +stdenv.mkDerivation { + pname = "vpcd"; + version = "unstable-2026-08-08"; + + src = fetchFromGitHub { + owner = "frankmorgner"; + repo = "vsmartcard"; + rev = "809675dc982addfc3fdb8cbaf177e4430477b0b2"; + hash = "sha256-I44XrC7v1G9hxaZ9zDlyUVPaSEx5HUAvDRBGsC8DYDs="; + }; + + sourceRoot = "source/virtualsmartcard"; + + nativeBuildInputs = [ + autoreconfHook + pkg-config + python3 + help2man + ]; + buildInputs = [ pcsclite ]; + + configureFlags = [ + "--enable-infoplist" + "--enable-serialdropdir=${placeholder "out"}/ifd-vpcd.bundle/Contents/MacOS" + "--enable-serialconfdir=${placeholder "out"}/ifd-vpcd.bundle/Contents" + ]; + + # ifd-vpcd.c calls log_msg() (via debuglog.h's Log2 macro) but its own + # Makefile.am never links against a library that provides it -- on Linux + # this is fine (pcscd, the process that dlopen()s driver .so files, + # already provides log_msg in its own address space at runtime, and ELF + # shared libraries tolerate unresolved symbols by default). macOS's + # linker doesn't tolerate this by default for dylibs; this is the + # standard fix for a plugin meant to be dlopen()'d by a host process + # that supplies the missing symbol, matching the real Linux behavior. + NIX_LDFLAGS = "-undefined dynamic_lookup"; + + meta = { + description = "Virtual PC/SC smart card reader driver (vpcd, from vsmartcard)"; + license = "GPL-3.0-or-later"; + platforms = [ "aarch64-darwin" "x86_64-darwin" ]; + }; +} diff --git a/scripts/provision_piv_emulation.py b/scripts/provision_piv_emulation.py new file mode 100644 index 0000000..7398c49 --- /dev/null +++ b/scripts/provision_piv_emulation.py @@ -0,0 +1,235 @@ +"""Register a virtual PIV card with macOS's smartcard stack, for real E2E sudo/PAM testing. + +Runs *locally* on whatever machine needs the virtual card (a Tart VM guest, +or a native CI runner) -- it is not a remote-orchestration script like +scripts/prewarm_vm.py, so it uses plain synchronous subprocess calls. + +Orchestrates, in order (see hack/plans/fix-vm-tahoe-base-image-1785337468-migration-mvp.md's +Task 10 Step 3 for the full research trail behind each choice): + +1. Build vpcd/jcardsim/pivapplet via the local Nix derivations in + nix/piv-emulation/ (never vendored binaries -- see PROJECT.md). +2. Start jcardsim's VSmartCard remote interface with a PivApplet-configured + jcardsim.cfg. +3. Select the applet via its AID. +4. Copy vpcd's built ifd-vpcd.bundle to the real system driver directory and + patch its Info.plist with the caller-supplied vendor/product ID -- the + Nix store copy is read-only, so patching happens on a real filesystem + copy, never in the store. Restart the driver host (not a full reboot). +5. Poll `system_profiler SPSmartCardsDataType` until the emulated card + appears (bounded retry -- this is a hard failure if it never appears, + not a soft skip; see Task 10 Step 4's own no-skip contract). +6. Provision the card's PIV slot 9a via yubico-piv-tool (a fresh card has + no usable keys -- arekinath/PivApplet#23's most-cited bug report was + exactly this being mistaken for a broken emulator). +7. Export the freshly-generated certificate into + ~/.eid/authorized_certificates -- the automated equivalent of + docs/runbooks/yubikey-piv.md's own manual cert-export step. + +Usage: ``uv run python scripts/provision_piv_emulation.py --vendor-id 1452 --product-id 33029`` +""" + +from __future__ import annotations + +import argparse +import logging +import shutil +import subprocess +import sys +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +_PIV_AID = "A000000308000010000100" +_SELECT_APPLET_APDU = "80 b8 00 00 12 0b a0 00 00 03 08 00 00 10 00 01 00 05 00 00 02 0F 0F 7f" +_DEFAULT_PIN = "123456" +_DRIVER_DEST = Path("/usr/local/libexec/SmartCardServices/drivers/ifd-vpcd.bundle") +_JCARDSIM_HOST = "127.0.0.1" +_JCARDSIM_VPCD_PORT = 35963 + + +class ProvisioningError(Exception): + """Raised when any provisioning stage fails.""" + + +def _run(cmd: list[str], *, timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess[str]: + logger.debug("Running: %s", cmd) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) # noqa: S603 + if check and result.returncode != 0: + raise ProvisioningError(f"{cmd!r} failed (exit {result.returncode}): {result.stderr.strip()}") + return result + + +_PIV_EMULATION_DIR = Path(__file__).resolve().parent.parent / "nix" / "piv-emulation" + + +def _nix_build(attr: str) -> Path: + # nix-build against the plain expression, not `nix build .#attr` -- + # mac2nix's own repo has no top-level flake.nix (only the generated + # scaffold template does, under src/mac2nix/templates/scaffold/). + result = _run( + ["nix-build", str(_PIV_EMULATION_DIR), "-A", attr, "--no-out-link"], + timeout=1800, + ) + return Path(result.stdout.strip()) + + +def _install_vpcd_bundle(vpcd_store_path: Path, vendor_id: int, product_id: int) -> None: + if _DRIVER_DEST.exists(): + _run(["sudo", "rm", "-rf", str(_DRIVER_DEST)]) + _run(["sudo", "mkdir", "-p", str(_DRIVER_DEST.parent)]) + _run(["sudo", "cp", "-R", str(vpcd_store_path / "ifd-vpcd.bundle"), str(_DRIVER_DEST.parent)]) + + info_plist = _DRIVER_DEST / "Contents" / "Info.plist" + # Info.plist stores these as single-element arrays of hex strings + # (verified against a real build this session -- ["0x18d1"] style, not + # a bare string) -- plutil's -json replace matches that shape exactly. + _run( + [ + "sudo", + "plutil", + "-replace", + "ifdVendorID", + "-json", + f'["0x{vendor_id:04x}"]', + str(info_plist), + ] + ) + _run( + [ + "sudo", + "plutil", + "-replace", + "ifdProductID", + "-json", + f'["0x{product_id:04x}"]', + str(info_plist), + ] + ) + + # Not a full reboot -- vsmartcard's own docs and a 2025 real-world + # resolution (frankmorgner/vsmartcard#303) confirm a driver-daemon + # restart is sufficient. + _run(["sudo", "killall", "-SIGKILL", "-m", ".*com.apple.ifdreader"], check=False) + + +def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.Popen[bytes]: + jcardsim_cfg = Path("jcardsim-mac2nix.cfg") + jcardsim_cfg.write_text( + f"com.licel.jcardsim.card.applet.0.AID={_PIV_AID}\n" + f"com.licel.jcardsim.card.applet.0.Class=net.cooperi.pivapplet.PivApplet\n" + f"com.licel.jcardsim.vsmartcard.host={_JCARDSIM_HOST}\n" + f"com.licel.jcardsim.vsmartcard.port={_JCARDSIM_VPCD_PORT}\n" + ) + classpath = f"{pivapplet_classes}:{jcardsim_jar}" + process = subprocess.Popen( # noqa: S603 + ["java", "-noverify", "-cp", classpath, "com.licel.jcardsim.remote.VSmartCard", str(jcardsim_cfg)], # noqa: S607 + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(2) # let the remote interface bind before anything tries to select the applet + if process.poll() is not None: + raise ProvisioningError( + "jcardsim's VSmartCard process exited immediately -- check jcardsim/PivApplet build output" + ) + return process + + +def _select_applet(reader_pattern: str = "Virtual PCD 00 00") -> None: + _run(["opensc-tool", "-r", reader_pattern, "-s", _SELECT_APPLET_APDU]) + + +def _wait_for_card(max_attempts: int = 10, delay_seconds: int = 3) -> None: + for attempt in range(max_attempts): + result = _run(["system_profiler", "SPSmartCardsDataType"], check=False) + if "Virtual PCD" in result.stdout or "PIV" in result.stdout: + return + logger.debug("Card not yet visible (attempt %d/%d)", attempt + 1, max_attempts) + time.sleep(delay_seconds) + raise ProvisioningError(f"Emulated PIV card did not appear in system_profiler after {max_attempts} attempts") + + +def _provision_piv_slot() -> None: + # A freshly-started card has no usable keys -- arekinath/PivApplet#23's + # most-cited bug report was exactly this being mistaken for a broken + # emulator. Slot 9a, RSA (yubico-piv-tool's default), matching the + # pre-built PivApplet .cap's own RSA/EC/AES/3DES feature set. + _run(["yubico-piv-tool", "-a", "generate", "-s", "9a", "-o", "pubkey.pem"]) + _run( + [ + "yubico-piv-tool", + "-a", + "verify-pin", + "-P", + _DEFAULT_PIN, + "-a", + "selfsign-certificate", + "-s", + "9a", + "-i", + "pubkey.pem", + "-S", + "/CN=mac2nix-piv-emulation/", + "-o", + "cert.pem", + ] + ) + _run(["yubico-piv-tool", "-a", "import-certificate", "-s", "9a", "-i", "cert.pem"]) + + +def _export_certificate() -> None: + eid_dir = Path.home() / ".eid" + eid_dir.mkdir(mode=0o755, exist_ok=True) + cert_pem = Path("cert.pem").read_text() + authorized = eid_dir / "authorized_certificates" + with authorized.open("a") as f: + f.write(cert_pem) + authorized.chmod(0o644) + + +def provision(vendor_id: int, product_id: int) -> None: + """Run the full provisioning sequence. Raises ProvisioningError on any failure.""" + if shutil.which("nix-build") is None: + raise ProvisioningError("nix-build is not on PATH -- required to build vpcd/jcardsim/pivapplet") + + vpcd_path = _nix_build("vpcd") + jcardsim_path = _nix_build("jcardsim") + pivapplet_path = _nix_build("pivapplet") + + _install_vpcd_bundle(vpcd_path, vendor_id, product_id) + + jcardsim_jar_candidates = list((jcardsim_path / "share").glob("**/jcardsim*.jar")) or list( + jcardsim_path.glob("**/jcardsim*.jar") + ) + if not jcardsim_jar_candidates: + raise ProvisioningError(f"No jcardsim jar found under {jcardsim_path}") + + process = _start_jcardsim(jcardsim_jar_candidates[0], pivapplet_path) + try: + _select_applet() + _wait_for_card() + _provision_piv_slot() + _export_certificate() + finally: + process.terminate() + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(message)s") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--vendor-id", type=int, required=True, help="USB vendor ID (decimal) to spoof for vpcd") + parser.add_argument("--product-id", type=int, required=True, help="USB product ID (decimal) to spoof for vpcd") + args = parser.parse_args() + + try: + provision(args.vendor_id, args.product_id) + except ProvisioningError as exc: + logger.error("PIV emulation provisioning failed: %s", exc) + return 1 + logger.info("Virtual PIV card provisioned and ready.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_provision_piv_emulation.py b/tests/test_provision_piv_emulation.py new file mode 100644 index 0000000..34267ef --- /dev/null +++ b/tests/test_provision_piv_emulation.py @@ -0,0 +1,156 @@ +"""Tests for scripts/provision_piv_emulation.py — mocked subprocess-level orchestration checks. + +Real verification of this script happens in tests/vm/test_piv_sudo_vm.py and +tests/vm/test_piv_sudo_native.py (actual VM/CI runs) — these tests only +assert each stage is invoked with the right arguments in the right order. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import provision_piv_emulation +import pytest + + +def _completed(stdout: str = "", returncode: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="") + + +class TestInstallVpcdBundle: + def test_patches_info_plist_with_hex_vendor_and_product_id(self, tmp_path: Path) -> None: + with ( + patch("provision_piv_emulation._DRIVER_DEST", tmp_path / "ifd-vpcd.bundle"), + patch("provision_piv_emulation._run", return_value=_completed()) as mock_run, + ): + provision_piv_emulation._install_vpcd_bundle(tmp_path / "store-path", vendor_id=1452, product_id=33029) + + calls = [c.args[0] for c in mock_run.call_args_list] + plutil_calls = [c for c in calls if "plutil" in c] + assert any('["0x05ac"]' in " ".join(c) for c in plutil_calls), plutil_calls + assert any('["0x8105"]' in " ".join(c) for c in plutil_calls), plutil_calls + + def test_removes_existing_bundle_before_copying(self, tmp_path: Path) -> None: + existing = tmp_path / "ifd-vpcd.bundle" + existing.mkdir() + with ( + patch("provision_piv_emulation._DRIVER_DEST", existing), + patch("provision_piv_emulation._run", return_value=_completed()) as mock_run, + ): + provision_piv_emulation._install_vpcd_bundle(tmp_path / "store-path", vendor_id=1, product_id=2) + + first_call = mock_run.call_args_list[0].args[0] + assert first_call[:3] == ["sudo", "rm", "-rf"] + + +class TestStartJcardsim: + def test_raises_if_process_exits_immediately(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) # _start_jcardsim writes jcardsim-mac2nix.cfg to the CWD + dead_process = MagicMock() + dead_process.poll.return_value = 1 + with ( + patch("provision_piv_emulation.subprocess.Popen", return_value=dead_process), + patch("provision_piv_emulation.time.sleep"), + pytest.raises(provision_piv_emulation.ProvisioningError, match="exited immediately"), + ): + provision_piv_emulation._start_jcardsim(tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes") + + def test_returns_process_when_still_running(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) # _start_jcardsim writes jcardsim-mac2nix.cfg to the CWD + live_process = MagicMock() + live_process.poll.return_value = None + with ( + patch("provision_piv_emulation.subprocess.Popen", return_value=live_process), + patch("provision_piv_emulation.time.sleep"), + ): + result = provision_piv_emulation._start_jcardsim(tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes") + assert result is live_process + + +class TestWaitForCard: + def test_returns_immediately_once_card_visible(self) -> None: + with ( + patch("provision_piv_emulation._run", return_value=_completed(stdout="... PIV ...")) as mock_run, + patch("provision_piv_emulation.time.sleep") as mock_sleep, + ): + provision_piv_emulation._wait_for_card(max_attempts=5) + assert mock_run.call_count == 1 + mock_sleep.assert_not_called() + + def test_raises_after_exhausting_attempts(self) -> None: + with ( + patch("provision_piv_emulation._run", return_value=_completed(stdout="nothing here")) as mock_run, + patch("provision_piv_emulation.time.sleep"), + pytest.raises(provision_piv_emulation.ProvisioningError, match="did not appear"), + ): + provision_piv_emulation._wait_for_card(max_attempts=3, delay_seconds=0) + assert mock_run.call_count == 3 + + +class TestProvisionOrchestration: + def test_runs_stages_in_order_and_cleans_up_jcardsim(self, tmp_path: Path) -> None: + process = MagicMock() + calls: list[str] = [] + + def _record(name: str) -> MagicMock: + def _fn(*_args: object, **_kwargs: object) -> object: + calls.append(name) + return None + + return MagicMock(side_effect=_fn) + + with ( + patch("provision_piv_emulation.shutil.which", return_value="/usr/bin/nix-build"), + patch("provision_piv_emulation._nix_build", side_effect=lambda attr: tmp_path / attr), + patch("provision_piv_emulation._install_vpcd_bundle", _record("install_vpcd")), + patch( + "provision_piv_emulation._start_jcardsim", + MagicMock(side_effect=lambda *_a: (calls.append("start_jcardsim"), process)[1]), + ), + patch("provision_piv_emulation._select_applet", _record("select_applet")), + patch("provision_piv_emulation._wait_for_card", _record("wait_for_card")), + patch("provision_piv_emulation._provision_piv_slot", _record("provision_piv_slot")), + patch("provision_piv_emulation._export_certificate", _record("export_certificate")), + patch("pathlib.Path.glob", return_value=[tmp_path / "jcardsim.jar"]), + ): + provision_piv_emulation.provision(vendor_id=1452, product_id=33029) + + assert calls == [ + "install_vpcd", + "start_jcardsim", + "select_applet", + "wait_for_card", + "provision_piv_slot", + "export_certificate", + ] + process.terminate.assert_called_once() + + def test_raises_if_nix_build_not_on_path(self) -> None: + with ( + patch("provision_piv_emulation.shutil.which", return_value=None), + pytest.raises(provision_piv_emulation.ProvisioningError, match="nix-build is not on PATH"), + ): + provision_piv_emulation.provision(vendor_id=1, product_id=2) + + +class TestMain: + def test_returns_1_and_logs_on_provisioning_error(self) -> None: + with ( + patch("sys.argv", ["provision_piv_emulation.py", "--vendor-id", "1452", "--product-id", "33029"]), + patch( + "provision_piv_emulation.provision", + side_effect=provision_piv_emulation.ProvisioningError("boom"), + ), + patch("provision_piv_emulation.logger") as mock_logger, + ): + assert provision_piv_emulation.main() == 1 + mock_logger.error.assert_called_once() + + def test_returns_0_on_success(self) -> None: + with ( + patch("sys.argv", ["provision_piv_emulation.py", "--vendor-id", "1452", "--product-id", "33029"]), + patch("provision_piv_emulation.provision"), + ): + assert provision_piv_emulation.main() == 0 From ad617cce0da92a7d80586a4eafeadc9d42e0e0f5 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 18:56:32 -0400 Subject: [PATCH 04/12] test(vm): adds real E2E coverage for the PIV sudo/PAM path 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 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. --- .github/workflows/pr-checks.yaml | 37 ++++- Makefile | 11 +- scripts/discover_usb_device.py | 85 +++++++++++ tests/test_discover_usb_device.py | 98 +++++++++++++ tests/vm/test_piv_sudo_native.py | 171 ++++++++++++++++++++++ tests/vm/test_piv_sudo_vm.py | 231 ++++++++++++++++++++++++++++++ 6 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 scripts/discover_usb_device.py create mode 100644 tests/test_discover_usb_device.py create mode 100644 tests/vm/test_piv_sudo_native.py create mode 100644 tests/vm/test_piv_sudo_vm.py diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 5e9d9d4..36870ee 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -75,7 +75,7 @@ jobs: git fetch origin "${{ github.base_ref }}" --depth=1 changed=$(git diff --name-only "origin/${{ github.base_ref }}" HEAD) echo "$changed" - if echo "$changed" | grep -qE '^(src/mac2nix/generators/|src/mac2nix/templates/|src/mac2nix/vm/|tests/generators/|tests/vm/|tests/vm_fixtures\.py)'; then + if echo "$changed" | grep -qE '^(src/mac2nix/generators/|src/mac2nix/templates/|src/mac2nix/vm/|tests/generators/|tests/vm/|tests/vm_fixtures\.py|nix/piv-emulation/|scripts/provision_piv_emulation\.py|scripts/discover_usb_device\.py)'; then echo "vm-relevant=true" >> "$GITHUB_OUTPUT" else echo "vm-relevant=false" >> "$GITHUB_OUTPUT" @@ -138,3 +138,38 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: make test-nix-darwin-switch + + # vpcd's macOS registration trick needs *some* USB-enumerated device on + # this host to spoof against — confirmed to always exist inside a Tart + # guest (a synthetic keyboard), but a real GitHub-hosted runner's own + # USB population has no prior precedent anywhere. Discover, don't + # assume: scripts/discover_usb_device.py's own stdout is already + # GITHUB_OUTPUT-formatted (vendor_id=.../product_id=...). + - name: Discover a baseline USB device for vpcd + id: discover-usb + run: | + if uv run python scripts/discover_usb_device.py >> "$GITHUB_OUTPUT"; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + # Must run *after* the switch above, never before or concurrently — + # see tests/vm/test_piv_sudo_native.py's own docstring: this splices a + # live PAM module into this runner's real /etc/pam.d/sudo_local for + # the rest of the job, so nothing earlier may still depend on it. + - name: Real E2E PIV sudo/PAM test (native runner) + if: steps.discover-usb.outputs.found == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAC2NIX_PIV_VENDOR_ID: ${{ steps.discover-usb.outputs.vendor_id }} + MAC2NIX_PIV_PRODUCT_ID: ${{ steps.discover-usb.outputs.product_id }} + run: make test-piv-sudo-native + + # Accepted, documented fallback, not a failure — Tart's own leg + # (test_piv_sudo_vm.py) remains the sole guaranteed E2E coverage of + # the sudo/PAM path regardless of this runner's own USB population. + - name: Native-runner PIV leg skipped (no usable USB device on this runner) + if: steps.discover-usb.outputs.found != 'true' + run: | + echo "::warning::No usable baseline USB device found on this runner image — the native-runner PIV sudo/PAM leg is infeasible here. Tart's own leg (test_piv_sudo_vm.py) remains the sole guaranteed E2E coverage." diff --git a/Makefile b/Makefile index 47803be..252b4d6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := all -.PHONY: install lint format typecheck test test-integration test-vm test-nix test-nix-darwin-switch test-op-cli prewarm-vm pull-base-vm test-quick clean all prek-install prek +.PHONY: install lint format typecheck test test-integration test-vm test-nix test-nix-darwin-switch test-piv-sudo-native test-op-cli prewarm-vm pull-base-vm test-quick clean all prek-install prek install: uv sync @@ -45,7 +45,14 @@ test-nix: # CI-only — skips unless GITHUB_ACTIONS=true (see the test module's own docstring). # Never invoke this on a real machine; it applies a genuine nix-darwin switch. test-nix-darwin-switch: - uv run pytest -m nix_darwin_switch --tb=long + uv run pytest tests/generators/test_scaffold_switch_native.py -m nix_darwin_switch --tb=long + +# CI-only, and must run as its own later step *after* test-nix-darwin-switch, +# never combined into the same pytest invocation — see +# tests/vm/test_piv_sudo_native.py's own docstring for why ordering here is a +# real security requirement, not a style preference. +test-piv-sudo-native: + uv run pytest tests/vm/test_piv_sudo_native.py -m nix_darwin_switch --tb=long # Requires a real, signed-in `op` CLI and MAC2NIX_TEST_OP_VAULT set to a disposable # test vault — skips otherwise (see tests/test_onepassword.py's op_test_vault fixture). diff --git a/scripts/discover_usb_device.py b/scripts/discover_usb_device.py new file mode 100644 index 0000000..a63cec3 --- /dev/null +++ b/scripts/discover_usb_device.py @@ -0,0 +1,85 @@ +"""Discover a baseline USB device on this machine, for vpcd's macOS registration trick. + +vpcd registers as a macOS smartcard reader driver by spoofing an arbitrary, +unrelated USB device's vendor/product ID in its Info.plist (see +nix/piv-emulation/vpcd.nix's own docstring for the full mechanism). A Tart +VM guest always has one (confirmed live this session: a synthetic +"Virtual USB Keyboard"), but a real GitHub Actions runner's baseline USB +population has never been checked by anyone -- this script is that check, +run as its own CI step before attempting PIV emulation on a native runner. + +Excludes any device that already self-identifies as smart-card-class +hardware, since reusing a real CCID reader's own VID/PID is a confirmed +real-world failure mode (frankmorgner/vsmartcard#303) rather than a +theoretical one. + +Prints `vendor_id=` and `product_id=` (decimal) to stdout for the +first suitable device found, one per line, and exits 0. Exits 1 with no +output if no suitable device exists. + +Usage: ``uv run python scripts/discover_usb_device.py`` +""" + +from __future__ import annotations + +import re +import subprocess +import sys + +# Names that indicate the device is already a smart-card-class reader -- +# reusing its VID/PID is the confirmed vsmartcard#303 failure mode, not a +# theoretical concern. +_EXCLUDED_NAME_PATTERNS = re.compile(r"smart\s*card|ccid|piv|yubikey", re.IGNORECASE) + +_NAME_FIELD_RE = re.compile(r'"USB Product Name"\s*=\s*"([^"]+)"') +_VENDOR_FIELD_RE = re.compile(r'"idVendor"\s*=\s*(\d+)') +_PRODUCT_FIELD_RE = re.compile(r'"idProduct"\s*=\s*(\d+)') + + +def _find_candidates(ioreg_output: str) -> list[tuple[str, int, int]]: + """Parse `ioreg -p IOUSB -l` output for (name, vendor_id, product_id) tuples. + + ioreg's per-device property order reflects the kernel's own internal + dictionary insertion order, not a fixed or alphabetical schema -- a real + capture against a live Tart guest this session showed idProduct, then + USB Product Name, then idVendor, an order that doesn't match either of + two hand-guessed alternatives tried first. Each field is matched + independently within its own device block instead of assuming any + particular sequential order between them. + """ + candidates: list[tuple[str, int, int]] = [] + # Split on device entry boundaries (a `+-o @` line starts each). + for block in re.split(r"\n\s*\+-o ", ioreg_output)[1:]: + name_match = _NAME_FIELD_RE.search(block) + vendor_match = _VENDOR_FIELD_RE.search(block) + product_match = _PRODUCT_FIELD_RE.search(block) + if not (name_match and vendor_match and product_match): + continue + candidates.append((name_match.group(1), int(vendor_match.group(1)), int(product_match.group(1)))) + return candidates + + +def find_usable_device() -> tuple[int, int] | None: + result = subprocess.run(["ioreg", "-p", "IOUSB", "-l"], capture_output=True, text=True, timeout=30, check=False) # noqa: S607 + if result.returncode != 0: + return None + + for name, vendor_id, product_id in _find_candidates(result.stdout): + if _EXCLUDED_NAME_PATTERNS.search(name): + continue + return vendor_id, product_id + return None + + +def main() -> int: + device = find_usable_device() + if device is None: + return 1 + vendor_id, product_id = device + print(f"vendor_id={vendor_id}") # noqa: T201 -- intentional stdout contract, consumed by CI + print(f"product_id={product_id}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_discover_usb_device.py b/tests/test_discover_usb_device.py new file mode 100644 index 0000000..e7ee3ce --- /dev/null +++ b/tests/test_discover_usb_device.py @@ -0,0 +1,98 @@ +"""Tests for scripts/discover_usb_device.py — parsing against real, captured ioreg output. + +The fixture below is a trimmed excerpt of `ioreg -p IOUSB -l` output +actually captured against a live Tart macOS guest this session (see +hack/PROJECT.md's "Task 10 (YubiKey PIV) expanded" entry) — not synthesized. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import discover_usb_device + +_REAL_TART_IOREG_EXCERPT = """ ++-o AppleUSBXHCIPCI@0e000000 + | { + | "IOClass" = "AppleUSBXHCIPCI" + | } + | + +-o Virtual USB Digitizer@0ea00000 + | { + | "sessionID" = 36244451 + | "idProduct" = 33030 + | "USB Product Name" = "Virtual USB Digitizer" + | "USB Vendor Name" = "Apple Inc." + | "idVendor" = 1452 + | } + | + +-o Virtual USB Keyboard@0e900000 + { + "sessionID" = 38530570 + "idProduct" = 33029 + "USB Product Name" = "Virtual USB Keyboard" + "USB Vendor Name" = "Apple Inc." + "idVendor" = 1452 + } +""" + +_IOREG_WITH_SMARTCARD_READER = """ ++-o Yubico YubiKey CCID@0e900000 + { + "idProduct" = 1031 + "USB Product Name" = "YubiKey CCID Smart Card Reader" + "idVendor" = 4176 + } +""" + + +class TestFindCandidates: + def test_parses_real_tart_ioreg_output(self) -> None: + candidates = discover_usb_device._find_candidates(_REAL_TART_IOREG_EXCERPT) + assert ("Virtual USB Digitizer", 1452, 33030) in candidates + assert ("Virtual USB Keyboard", 1452, 33029) in candidates + + def test_no_candidates_in_empty_output(self) -> None: + assert discover_usb_device._find_candidates("") == [] + + +class TestFindUsableDevice: + def test_returns_first_non_smartcard_device(self) -> None: + with patch( + "discover_usb_device.subprocess.run", + return_value=type("Result", (), {"returncode": 0, "stdout": _REAL_TART_IOREG_EXCERPT, "stderr": ""})(), + ): + result = discover_usb_device.find_usable_device() + assert result == (1452, 33030) # Digitizer appears first in the fixture + + def test_excludes_smartcard_class_devices(self) -> None: + with patch( + "discover_usb_device.subprocess.run", + return_value=type("Result", (), {"returncode": 0, "stdout": _IOREG_WITH_SMARTCARD_READER, "stderr": ""})(), + ): + result = discover_usb_device.find_usable_device() + assert result is None + + def test_returns_none_when_ioreg_fails(self) -> None: + with patch( + "discover_usb_device.subprocess.run", + return_value=type("Result", (), {"returncode": 1, "stdout": "", "stderr": "denied"})(), + ): + result = discover_usb_device.find_usable_device() + assert result is None + + +class TestMain: + def test_prints_vendor_and_product_id_and_returns_0(self, capsys) -> None: + with patch("discover_usb_device.find_usable_device", return_value=(1452, 33029)): + exit_code = discover_usb_device.main() + assert exit_code == 0 + captured = capsys.readouterr() + assert "vendor_id=1452" in captured.out + assert "product_id=33029" in captured.out + + def test_returns_1_with_no_output_when_nothing_found(self, capsys) -> None: + with patch("discover_usb_device.find_usable_device", return_value=None): + exit_code = discover_usb_device.main() + assert exit_code == 1 + assert capsys.readouterr().out == "" diff --git a/tests/vm/test_piv_sudo_native.py b/tests/vm/test_piv_sudo_native.py new file mode 100644 index 0000000..30f37c4 --- /dev/null +++ b/tests/vm/test_piv_sudo_native.py @@ -0,0 +1,171 @@ +"""Real PIV-card-authenticates-against-sudo-PAM test, applied natively — no VM. + +Marked `nix_darwin_switch`, same skip-unless-`GITHUB_ACTIONS=true` guard as +`test_scaffold_switch_native.py` — never runs against a real developer +machine. Unlike that test, this one requires two more environment +variables (`MAC2NIX_PIV_VENDOR_ID`/`MAC2NIX_PIV_PRODUCT_ID`) that only exist +when `pr-checks.yaml`'s discovery step (`scripts/discover_usb_device.py`) +found a usable baseline USB device on this specific runner — if it didn't, +the CI workflow's own conditional skips the step that would run this test +entirely, which is a deliberate, documented fallback (see +hack/plans/fix-vm-tahoe-base-image-1785337468-migration-mvp.md's Task 10 +Step 5), not something this test itself needs to handle. + +Must run *after* `test_scaffold_switch_native.py`'s own switch step in the +CI workflow, never before or concurrently — see +`scripts/provision_piv_emulation.py`'s own docstring and this project's +security review: this test's own switch (with the PIV option enabled) +splices a live PAM module into this runner's real `/etc/pam.d/sudo_local` +for the remainder of the job, and nothing else in that job may still depend +on plain-password `sudo` succeeding once it does. +""" + +from __future__ import annotations + +import asyncio +import getpass +import os +import shutil +import subprocess +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from mac2nix.generators.scaffold import add_host, init_framework +from tests._scaffold_helpers import _nix_config_env_prefix_args + +pytestmark = pytest.mark.nix_darwin_switch + +_HOSTNAME = "mac2nix-piv-sudo-native-test" + + +def _is_github_actions() -> bool: + return os.environ.get("GITHUB_ACTIONS") == "true" + + +def _enable_yubikey_piv_sudo(output_dir: Path, hostname: str) -> None: + config_path = output_dir / "hosts" / "darwin" / hostname / "configuration.nix" + content = config_path.read_text() + marker = "system.stateVersion = 7;" + replacement = f"{marker}\n mac2nix.yubikeyPivSudo.enable = true;" + config_path.write_text(content.replace(marker, replacement)) + + +@pytest.fixture +def real_age_key() -> Iterator[Path]: + if not _is_github_actions(): + pytest.skip("only runs under GITHUB_ACTIONS=true — never against a real developer machine") + + username = getpass.getuser() + key_path = Path(f"/Users/{username}/.config/sops/age/keys.txt") + if key_path.exists(): + pytest.fail( + f"a real sops age key already exists at {key_path} — refusing to overwrite or reuse it. " + "This should never happen on a fresh CI runner." + ) + + try: + yield key_path + finally: + key_path.unlink(missing_ok=True) + + +@pytest.fixture +def discovered_usb_device() -> tuple[int, int]: + """The USB device pr-checks.yaml's discovery step found on this runner. + + Fails loudly (not skips) if these are missing while GITHUB_ACTIONS=true — + the workflow's own conditional step is what decides whether this test + runs at all; if it ran, the env vars must be present. + """ + vendor_id = os.environ.get("MAC2NIX_PIV_VENDOR_ID") + product_id = os.environ.get("MAC2NIX_PIV_PRODUCT_ID") + if not vendor_id or not product_id: + raise AssertionError( + "MAC2NIX_PIV_VENDOR_ID/MAC2NIX_PIV_PRODUCT_ID are not set — this test should only ever " + "run from pr-checks.yaml's conditional step, after scripts/discover_usb_device.py found " + "a usable device." + ) + return int(vendor_id), int(product_id) + + +def test_piv_card_authenticates_against_sudo_pam_natively( + real_age_key: Path, discovered_usb_device: tuple[int, int], tmp_path: Path +) -> None: + """A real virtual PIV card, provisioned directly on this runner, authenticates via pam_p11.""" + username = getpass.getuser() + output_dir = tmp_path / "mac2nix-scaffold" + vendor_id, product_id = discovered_usb_device + + init_framework(output_dir) + add_host(output_dir, _HOSTNAME, username, confirm_backup=lambda _fingerprint: True) + _enable_yubikey_piv_sudo(output_dir, _HOSTNAME) + + assert real_age_key.is_file(), "add_host() should have written the real age key to the real expected path" + + nix_bin = shutil.which("nix") + assert nix_bin is not None, "nix must be installed on this runner before this test can apply anything" + sudo_bin = shutil.which("sudo") + assert sudo_bin is not None, "sudo must be available to run nix-darwin's system activation" + nix_config_prefix = _nix_config_env_prefix_args() + + async def _switch() -> tuple[int, str, str]: + proc = await asyncio.create_subprocess_exec( + sudo_bin, + "-n", + *nix_config_prefix, + nix_bin, + "run", + "nix-darwin", + "--", + "switch", + "--flake", + f".#{_HOSTNAME}", + cwd=output_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=900) + return proc.returncode or 0, stdout.decode(), stderr.decode() + + returncode, out, err = asyncio.run(_switch()) + assert returncode == 0, ( + f"sudo nix run nix-darwin -- switch failed (exit {returncode}):\nstdout:\n{out}\nstderr:\n{err}" + ) + + repo_root = Path(__file__).resolve().parents[2] + provision_script = repo_root / "scripts" / "provision_piv_emulation.py" + provision_result = subprocess.run( # noqa: S603 + [ + sudo_bin, + "-n", + nix_bin, + "run", + "nixpkgs#python3", + "--", + str(provision_script), + "--vendor-id", + str(vendor_id), + "--product-id", + str(product_id), + ], + capture_output=True, + text=True, + timeout=1800, + check=False, + ) + assert provision_result.returncode == 0, ( + f"PIV emulation provisioning failed:\nstdout:\n{provision_result.stdout}\nstderr:\n{provision_result.stderr}" + ) + + pamtester_result = subprocess.run( # noqa: S603 + ["bash", "-c", f"echo 123456 | {nix_bin} run nixpkgs#pamtester -- sudo_local {username} authenticate"], # noqa: S607 + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert pamtester_result.returncode == 0, ( + f"pamtester authentication failed:\nstdout:\n{pamtester_result.stdout}\nstderr:\n{pamtester_result.stderr}" + ) diff --git a/tests/vm/test_piv_sudo_vm.py b/tests/vm/test_piv_sudo_vm.py new file mode 100644 index 0000000..ae886d4 --- /dev/null +++ b/tests/vm/test_piv_sudo_vm.py @@ -0,0 +1,231 @@ +"""Real VM-based E2E test: a virtual PIV card authenticating against the sudo PAM path. + +Marked `nix_vm` — no-skip once `tart` is present, same contract as every +other `nix_vm` test in this codebase (the earlier idea of softening this +given vpcd's own documented macOS-version sensitivity was considered and +explicitly rejected; see hack/PROJECT.md's "Task 10 (YubiKey PIV) expanded" +entry). + +Proves `security.nix`'s `mac2nix.yubikeyPivSudo.enable` PAM wiring actually +authenticates against a real card -- not just that it builds (Step 1's +nix_build-marked tests already cover that). Uses vpcd + jcardsim + PivApplet +(nix/piv-emulation/, scripts/provision_piv_emulation.py) instead of a +physical YubiKey, per this plan's own research spike +(hack/research/feat-migration-mvp-pr1-1786215169-piv-smartcard-emulation-tart-macos.md). + +The spoof target (Virtual USB Keyboard, idVendor 1452 / idProduct 33029) was +confirmed live this session via `ioreg -p IOUSB -l` against a real Tart +guest -- it is not documented anywhere upstream, it was discovered here. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from mac2nix.generators.scaffold import add_host, init_framework +from mac2nix.vm._utils import VMError, async_run_command +from mac2nix.vm.manager import TartVMManager +from mac2nix.vm.validator import Validator + +pytestmark = pytest.mark.nix_vm + +_HOSTNAME = "mac2nix-piv-sudo-vm-test" + +# Tart's base images ship a real, pre-existing "admin" account -- same +# default TartVMManager itself uses for SSH. Mirrors test_scaffold_vm.py's +# own reasoning for reusing this account rather than a synthetic one. +_VM_USERNAME = "admin" + +# Confirmed live this session via a real ioreg spike against macos-tahoe-base +# -- a baseline synthetic USB device Virtualization.framework always +# provides for guest keyboard input, present with zero configuration. +_VENDOR_ID = 1452 +_PRODUCT_ID = 33029 + +_REMOTE_PIV_ROOT = "/tmp/mac2nix-piv" +_NIX_PROFILE_SOURCE_CMD = ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" + + +def _enable_yubikey_piv_sudo(output_dir: Path, hostname: str) -> None: + """Patch a registered host's configuration.nix to set `mac2nix.yubikeyPivSudo.enable = true;`. + + Mirrors how a real user enables the option -- hand-editing their host's + own configuration.nix -- rather than inventing a test-only override. + """ + config_path = output_dir / "hosts" / "darwin" / hostname / "configuration.nix" + content = config_path.read_text() + marker = "system.stateVersion = 7;" + replacement = f"{marker}\n mac2nix.yubikeyPivSudo.enable = true;" + config_path.write_text(content.replace(marker, replacement)) + + +async def _copy_age_key_to_vm(vm: TartVMManager, local_key_path: Path, username: str) -> None: + """SCP the local age key into the VM at the exact path `lib/helpers.nix` expects. + + Identical to test_scaffold_vm.py's own helper -- not shared via import + across test modules, matching this codebase's existing convention of + small per-file test helpers over cross-test-module coupling. + """ + ip = await vm.get_ip() + if not ip: + raise VMError("Cannot copy age key — VM has no IP address") + + remote_dir = f"/Users/{username}/.config/sops/age" + ok, _out, err = await vm.exec_command(["mkdir", "-p", remote_dir]) + if not ok: + raise VMError(f"mkdir {remote_dir!r} failed: {err.strip()}") + + scp_cmd = [ + "sshpass", + "-e", + "scp", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + str(local_key_path), + f"{vm.vm_user}@{ip}:{remote_dir}/keys.txt", + ] + returncode, _stdout, stderr = await async_run_command(scp_cmd, timeout=30, env={"SSHPASS": vm.vm_password}) + if returncode != 0: + raise VMError(f"scp age key to VM failed (exit {returncode}): {stderr.strip()}") + + ok, _out, err = await vm.exec_command(["chmod", "600", f"{remote_dir}/keys.txt"]) + if not ok: + raise VMError(f"chmod age key in VM failed: {err.strip()}") + + +async def _switch_scaffold(vm: TartVMManager, validator: Validator, output_dir: Path, local_key_path: Path) -> None: + """Real `nix run nix-darwin -- switch`, reusing test_scaffold_vm.py's exact fixups verbatim.""" + await validator._copy_flake_to_vm(output_dir) + await _copy_age_key_to_vm(vm, local_key_path, _VM_USERNAME) + await validator._bootstrap_nix_darwin() + + move_cmd = ( + "if [ -f /etc/nix/nix.custom.conf ]; then " + "sudo mv /etc/nix/nix.custom.conf /etc/nix/nix.custom.conf.before-nix-darwin; " + "fi" + ) + ok, _out, err = await vm.exec_command(["bash", "-c", move_cmd]) + if not ok: + raise VMError(f"Failed to move aside /etc/nix/nix.custom.conf: {err.strip()}") + + ok, _out, err = await vm.exec_command(["sudo", "rm", "-rf", "/opt/homebrew"], timeout=60) + if not ok: + raise VMError(f"Failed to remove pre-existing Homebrew: {err.strip()}") + + switch_cmd = ( + f"cd {validator._REMOTE_FLAKE_DIR}" + " && . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" + f" && sudo -n $(command -v nix) run nix-darwin -- switch --flake .#{_HOSTNAME}" + ) + ok, out, err = await vm.exec_command(["bash", "-c", switch_cmd], timeout=900) + if not ok: + raise VMError(f"nix run nix-darwin -- switch failed:\nstdout:\n{out}\nstderr:\n{err}") + + +async def _copy_provisioning_assets(vm: TartVMManager, validator: Validator) -> None: + repo_root = Path(__file__).resolve().parents[2] + ok, _out, err = await vm.exec_command(["mkdir", "-p", _REMOTE_PIV_ROOT]) + if not ok: + raise VMError(f"mkdir {_REMOTE_PIV_ROOT!r} failed: {err.strip()}") + await validator._copy_flake_to_vm( + repo_root / "scripts", remote_dir=f"{_REMOTE_PIV_ROOT}/scripts", what="provisioning script" + ) + await validator._copy_flake_to_vm( + repo_root / "nix", remote_dir=f"{_REMOTE_PIV_ROOT}/nix", what="piv-emulation derivations" + ) + + +async def _run_provisioning(vm: TartVMManager) -> None: + provision_cmd = ( + f"{_NIX_PROFILE_SOURCE_CMD}" + f" && cd {_REMOTE_PIV_ROOT}" + f" && sudo -n $(command -v nix) run nixpkgs#python3 -- scripts/provision_piv_emulation.py" + f" --vendor-id {_VENDOR_ID} --product-id {_PRODUCT_ID}" + ) + ok, out, err = await vm.exec_command(["bash", "-c", provision_cmd], timeout=1800) + if not ok: + raise VMError(f"PIV emulation provisioning failed:\nstdout:\n{out}\nstderr:\n{err}") + + +async def _pamtester_authenticate(vm: TartVMManager, *, pin: str) -> tuple[bool, str, str]: + cmd = ( + f"{_NIX_PROFILE_SOURCE_CMD} && echo {pin} | nix run nixpkgs#pamtester -- sudo_local {_VM_USERNAME} authenticate" + ) + return await vm.exec_command(["bash", "-c", cmd], timeout=60) + + +def test_piv_card_authenticates_against_sudo_pam( + nix_darwin_vm: TartVMManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A real virtual PIV card, once provisioned, authenticates via pam_p11 against sudo_local.""" + key_root = tmp_path / "age-keys" + + def _fake_age_key_path(username: str, key_dir: Path | None = None) -> Path: + return (key_dir or key_root / username) / "keys.txt" + + monkeypatch.setattr("mac2nix.generators.scaffold._age_key_path", _fake_age_key_path) + + output_dir = tmp_path / "mac2nix-scaffold" + init_framework(output_dir) + add_host(output_dir, _HOSTNAME, _VM_USERNAME, confirm_backup=lambda _fingerprint: True) + _enable_yubikey_piv_sudo(output_dir, _HOSTNAME) + + local_key_path = _fake_age_key_path(_VM_USERNAME) + + async def _run() -> tuple[bool, str, str]: + validator = Validator(nix_darwin_vm) + await _switch_scaffold(nix_darwin_vm, validator, output_dir, local_key_path) + await _copy_provisioning_assets(nix_darwin_vm, validator) + await _run_provisioning(nix_darwin_vm) + + # Attribution matters: touchIdAuth sits before pam_p11 in the same + # `sufficient` chain (lib.mkAfter) and is unconditional. A bare + # "authentication succeeded" assertion could pass for an unrelated + # reason on hardware with no biometric sensor -- masking a broken + # PIV path entirely. The negative case below is what actually proves + # attribution, not this call alone. + return await _pamtester_authenticate(nix_darwin_vm, pin="123456") + + ok, out, err = asyncio.run(_run()) + assert ok, f"pamtester authentication failed:\nstdout:\n{out}\nstderr:\n{err}" + + +def test_piv_card_wrong_pin_fails_authentication( + nix_darwin_vm: TartVMManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Negative case: proves the prior test's success is attributable to the PIV path specifically. + + A test that can never fail regardless of whether the PIV wiring works is + not real coverage -- this is that check. + """ + key_root = tmp_path / "age-keys" + + def _fake_age_key_path(username: str, key_dir: Path | None = None) -> Path: + return (key_dir or key_root / username) / "keys.txt" + + monkeypatch.setattr("mac2nix.generators.scaffold._age_key_path", _fake_age_key_path) + + output_dir = tmp_path / "mac2nix-scaffold" + init_framework(output_dir) + add_host(output_dir, _HOSTNAME, _VM_USERNAME, confirm_backup=lambda _fingerprint: True) + _enable_yubikey_piv_sudo(output_dir, _HOSTNAME) + + local_key_path = _fake_age_key_path(_VM_USERNAME) + + async def _run() -> tuple[bool, str, str]: + validator = Validator(nix_darwin_vm) + await _switch_scaffold(nix_darwin_vm, validator, output_dir, local_key_path) + await _copy_provisioning_assets(nix_darwin_vm, validator) + await _run_provisioning(nix_darwin_vm) + + return await _pamtester_authenticate(nix_darwin_vm, pin="000000") + + ok, out, _err = asyncio.run(_run()) + assert not ok, f"pamtester authenticated with a wrong PIN — PIV path is not actually gating auth:\n{out}" From 21b230868c7c45b7720cdc940ab231f95414e39e Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 19:14:20 -0400 Subject: [PATCH 05/12] fix(vm): fully verifies jcardsim/pivapplet builds for real 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. --- nix/piv-emulation/jcardsim.nix | 72 ++++++++++++++++++++++----------- nix/piv-emulation/pivapplet.nix | 24 +++++++---- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/nix/piv-emulation/jcardsim.nix b/nix/piv-emulation/jcardsim.nix index 79fb60e..4b09d07 100644 --- a/nix/piv-emulation/jcardsim.nix +++ b/nix/piv-emulation/jcardsim.nix @@ -17,11 +17,11 @@ # reached, since buildMavenPackage's default `package` goal stops before that # lifecycle phase. { - lib, fetchFromGitHub, fetchurl, maven, jdk8, + runCommand, }: let apiClassicJar = fetchurl { @@ -29,14 +29,17 @@ let hash = "sha256-xDCNvuGS3D8SUJEhZg0HG0b6vUQjxL+XFH4DnB0WLtg="; }; - installApiClassic = '' - mvn -B install:install-file \ - -Dfile=${apiClassicJar} \ - -DgroupId=oracle.javacard \ - -DartifactId=api_classic \ - -Dversion=3.0.5 \ - -Dpackaging=jar \ - -Dmaven.repo.local=$out/.m2 + # jcardsim's own pom.xml has a maven-install-plugin execution bound + # directly to the build lifecycle, reading `${env.JC_CLASSIC_HOME}/lib/ + # api_classic.jar` -- a real, on-disk directory shaped exactly like a JC + # kit's own layout is what it actually needs, not a manually-run + # `install:install-file` invocation (verified against a real build: a + # separate manual install-file call put the jar in the local repo, but + # left this pom-bound execution failing on the literal, unexpanded + # "${env.JC_CLASSIC_HOME}" string, since the env var itself was never set). + jcClassicHome = runCommand "jc-classic-home" { } '' + mkdir -p $out/lib + cp ${apiClassicJar} $out/lib/api_classic.jar ''; in maven.buildMavenPackage { @@ -53,24 +56,45 @@ maven.buildMavenPackage { mvnJdk = jdk8; mvnGoal = "package"; doCheck = false; + env.JC_CLASSIC_HOME = "${jcClassicHome}"; - # api_classic must already be in the local repo before the dependency - # prefetch's own `mvn package` runs, or that prefetch itself fails outright - # trying (and failing) to resolve it from a real repo. + # Verified against a real build: Maven resolves a project's own + # compile-scope dependencies before running that same build's phase-bound + # plugin executions, so the pom-bound install-file execution (which + # would otherwise satisfy oracle.javacard:api_classic) can never run in + # time to help a single `mvn package` invocation compile itself -- this + # is exactly why upstream's own documented build is two separate + # invocations (`mvn initialize` then `mvn clean install`), not one. + # `afterDepsSetup` is an existing extension point in buildMavenPackage's + # own generated buildPhase (runs after the offline .m2 cache is staged, + # before the real `mvn package`) -- this is that first `mvn initialize` + # pass, scoped to the local repo copy the real build will use. + afterDepsSetup = '' + mvn initialize -Dmaven.repo.local=$mvnDeps/.m2 + ''; + + # The dependency-prefetch derivation (fetchedMavenDeps) runs the full + # `mvn package` goal too, in non-offline mode -- it hits the exact same + # ordering problem, so it needs its own `mvn initialize` pass first, + # against its own local repo path ($out/.m2, not $mvnDeps/.m2). mvnFetchExtraArgs = { - preBuild = installApiClassic; + env.JC_CLASSIC_HOME = "${jcClassicHome}"; + preBuild = '' + mvn initialize -Dmaven.repo.local=$out/.m2 + ''; }; - # [ASSUMPTION: verify on first real build] lib.fakeHash is a deliberate - # placeholder, not an oversight -- this project's own dev sandbox has no - # network route to repo.maven.apache.org (confirmed: github.com/ - # cache.nixos.org fetches all work fine from here; Maven Central does - # not, even with the build sandbox disabled, so the restriction sits - # below Nix's own sandboxing). Any environment with real network access - # (CI, a Tart VM) will report the correct hash on first build via Nix's - # standard "hash mismatch: got sha256-..." error -- replace this value - # with that real hash once available, the same way vpcd.nix's and - # pivapplet.nix's hashes were already verified for real in this session. - mvnHash = lib.fakeHash; + mvnHash = "sha256-LqPIhjDVFHjohWZXNd8lOgHK7AgRno6hkgByhuLtxzo="; # verified via a real build this session + + # buildMavenPackage has no default installPhase -- the shaded jar + # (target/jcardsim-3.0.5-SNAPSHOT.jar, already replaced in place by the + # shade plugin's first execution per the build log) is the one the + # documented `-cp bin/:jcardsim-3.0.5-SNAPSHOT.jar` classpath usage + # expects -- not the separate -android.jar the second shade execution + # also produces, which is for a different (Android) target entirely. + installPhase = '' + mkdir -p $out/share/java + cp target/jcardsim-3.0.5-SNAPSHOT.jar $out/share/java/ + ''; meta = { description = "Pure-Java Card Runtime simulator (arekinath fork, vpcd-enabled)"; diff --git a/nix/piv-emulation/pivapplet.nix b/nix/piv-emulation/pivapplet.nix index 06c3ad7..fdbf938 100644 --- a/nix/piv-emulation/pivapplet.nix +++ b/nix/piv-emulation/pivapplet.nix @@ -19,7 +19,6 @@ # at a 2.2.2 kit), from the same martinpaljak/oracle_javacard_sdks mirror # jcardsim.nix already uses and documents the trade-off for. { - lib, fetchFromGitHub, stdenv, jdk8, @@ -30,7 +29,7 @@ let owner = "martinpaljak"; repo = "oracle_javacard_sdks"; rev = "6a75ec0d6913db236d354f154df7dbc9573d976d"; - hash = lib.fakeHash; + hash = "sha256-RJTus6PjN5f+WfN+N44HIkSgFc8QHHIMY+Fps0M7XF4="; # verified via a real build this session sparseCheckout = [ "jc222_kit" ]; }; in @@ -48,7 +47,7 @@ stdenv.mkDerivation { # plain committed file, not a submodule, so a normal fetch already # includes it. fetchSubmodules = true; - hash = lib.fakeHash; + hash = "sha256-Ecf/lv54dC9lUzuKTw/WU/vq1ptzgY3vHH26ZvQlLPo="; # verified via a real build this session }; nativeBuildInputs = [ @@ -60,12 +59,23 @@ stdenv.mkDerivation { # build.xml's `dist` target builds ext/ant (ant-javacard) first, then # preprocesses+compiles+packages the applet -- see build.xml's own - # `dist`/`preprocess` targets. Real network access to Maven Central - # (ant-javacard's own build dependency) is required and not available in - # this project's dev sandbox -- see jcardsim.nix's identical note. + # `dist`/`preprocess` targets. + # + # PIV_SUPPORT_EC=false: verified against a real build and the actual + # source (not just build.xml's own property comments, which turned out + # to be misleading here) that `processGenAuthEcPlain()` -- gated only by + # `#if PIV_SUPPORT_EC`, not by PIV_USE_EC_PRECOMPHASH as build.xml's own + # doc comment implies -- unconditionally calls Signature. + # signPreComputedHash(), a JC3.0.4+-only API with no equivalent in the + # JC 2.2.2 target this derivation builds against (confirmed: setting + # PIV_USE_EC_PRECOMPHASH=false alone did not change the compile error at + # all). This project's own use (an RSA key in PIV slot 9a, per + # scripts/provision_piv_emulation.py) never needs EC/ECDSA support, so + # disabling it entirely sidesteps the incompatibility rather than fighting + # an API JC 2.2.2 genuinely does not have. buildPhase = '' runHook preBuild - ant dist + ant -DPIV_SUPPORT_EC=false dist runHook postBuild ''; From 871f017d2b8973d1d1e7cbfd50cf427ff6b82420 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 13:27:16 -0400 Subject: [PATCH 06/12] fix(vm): retries the boot-settling SSH auth race after wait_ready 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. --- src/mac2nix/vm/_utils.py | 12 +++++ src/mac2nix/vm/manager.py | 12 +++++ src/mac2nix/vm/validator.py | 12 ++++- tests/vm/test_manager.py | 95 ++++++++++++++++++++++++++++++++++++ tests/vm/test_piv_sudo_vm.py | 17 ++++++- tests/vm/test_scaffold_vm.py | 19 +++++++- tests/vm/test_validator.py | 46 +++++++++++++++++ tests/vm/test_vm_utils.py | 32 ++++++++++++ 8 files changed, 242 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/vm/_utils.py b/src/mac2nix/vm/_utils.py index b96a445..934025f 100644 --- a/src/mac2nix/vm/_utils.py +++ b/src/mac2nix/vm/_utils.py @@ -39,6 +39,18 @@ def is_sshpass_available() -> bool: return shutil.which("sshpass") is not None +def is_transient_auth_failure(stderr: str) -> bool: + """Return True if *stderr* matches the known VM-boot-settling auth race. + + A freshly-booted VM can briefly reject the exact same credentials moments + after `TartVMManager.wait_ready()` itself already confirmed two + consecutive successful SSH connections — reproduced empirically across + real VM runs (account/password state still settling). Not a real + credential mismatch: retrying once after a short delay resolves it. + """ + return "permission denied, please try again" in stderr.lower() + + # --------------------------------------------------------------------------- # Async subprocess helpers # --------------------------------------------------------------------------- diff --git a/src/mac2nix/vm/manager.py b/src/mac2nix/vm/manager.py index b4dc608..a66dd1e 100644 --- a/src/mac2nix/vm/manager.py +++ b/src/mac2nix/vm/manager.py @@ -14,6 +14,7 @@ VMTimeoutError, async_run_command, async_ssh_exec, + is_transient_auth_failure, ) logger = logging.getLogger(__name__) @@ -302,6 +303,10 @@ async def exec_command( """Execute *cmd* inside the VM via SSH. Detects transient SSH disconnects and retries once with ``timeout * 2``. + Also retries once (after a short delay, same IP) on the known + boot-settling auth race described in :func:`is_transient_auth_failure` + — `wait_ready()`'s own two-consecutive-success check isn't always + sufficient, reproduced empirically across real VM runs. Returns: Tuple of (success, stdout, stderr). @@ -314,6 +319,13 @@ async def exec_command( success, out, err = await self._ssh_exec_raw(ip, cmd, timeout=timeout) + if not success and is_transient_auth_failure(err): + logger.info("Transient boot-settling auth failure for %r — retrying once in 3s", clone) + await asyncio.sleep(3) + success, out, err = await self._ssh_exec_raw(ip, cmd, timeout=timeout) + if not success: + logger.warning("Auth-failure retry also failed for %r: %s", clone, err.strip()) + # Detect transient disconnect and retry once. if not success and self._is_disconnect(err): logger.info("SSH disconnect detected for %r — retrying once (timeout=%ds)", clone, timeout * 2) diff --git a/src/mac2nix/vm/validator.py b/src/mac2nix/vm/validator.py index c192a82..ad97309 100644 --- a/src/mac2nix/vm/validator.py +++ b/src/mac2nix/vm/validator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import tempfile from pathlib import Path @@ -10,7 +11,7 @@ from pydantic import BaseModel from mac2nix.models.system_state import SystemState -from mac2nix.vm._utils import VMError, async_run_command +from mac2nix.vm._utils import VMError, async_run_command, is_transient_auth_failure from mac2nix.vm.manager import TartVMManager logger = logging.getLogger(__name__) @@ -302,6 +303,15 @@ async def _copy_flake_to_vm( returncode, _stdout, stderr = await async_run_command( scp_cmd, timeout=120, env={"SSHPASS": self._vm.vm_password} ) + if returncode != 0 and is_transient_auth_failure(stderr): + # Same boot-settling auth race documented on + # TartVMManager.exec_command() — scp bypasses that retry entirely + # since it never goes through exec_command(), so it needs its own. + logger.info("Transient boot-settling auth failure copying %s — retrying once in 3s", what) + await asyncio.sleep(3) + returncode, _stdout, stderr = await async_run_command( + scp_cmd, timeout=120, env={"SSHPASS": self._vm.vm_password} + ) if returncode != 0: raise VMError(f"scp {what} to VM failed (exit {returncode}): {stderr.strip()}") diff --git a/tests/vm/test_manager.py b/tests/vm/test_manager.py index adc14a7..3389525 100644 --- a/tests/vm/test_manager.py +++ b/tests/vm/test_manager.py @@ -804,6 +804,101 @@ async def disconnect_ssh(ip, user, pw, cmd, *, timeout): assert success is False assert "IP" in stderr or "ip" in stderr.lower() + def test_transient_auth_failure_triggers_retry(self) -> None: + call_count = 0 + + async def _run() -> tuple[bool, str, str]: + nonlocal call_count + mgr = _cloned_manager("auth-retry-vm") + + async def flaky_ssh(ip, user, pw, cmd, *, timeout): + nonlocal call_count + call_count += 1 + if call_count == 1: + return (False, "", "Permission denied, please try again.") + return (True, "ok", "") + + with ( + patch.object(mgr, "get_ip", new=AsyncMock(return_value="10.0.0.1")), + patch("mac2nix.vm.manager.async_ssh_exec", side_effect=flaky_ssh), + patch("mac2nix.vm.manager.asyncio.sleep", new=AsyncMock()), + ): + return await mgr.exec_command(["ls"], timeout=30) + + success, _stdout, _ = asyncio.run(_run()) + assert success is True + assert call_count == 2 + + def test_transient_auth_retry_does_not_double_timeout(self) -> None: + timeouts_used: list[int] = [] + + async def _run() -> None: + mgr = _cloned_manager("auth-retry-timeout-vm") + + async def recording_ssh(ip, user, pw, cmd, *, timeout): + timeouts_used.append(timeout) + return (False, "", "Permission denied, please try again.") + + with ( + patch.object(mgr, "get_ip", new=AsyncMock(return_value="10.0.0.1")), + patch("mac2nix.vm.manager.async_ssh_exec", side_effect=recording_ssh), + patch("mac2nix.vm.manager.asyncio.sleep", new=AsyncMock()), + ): + await mgr.exec_command(["ls"], timeout=30) + + asyncio.run(_run()) + # Unlike the disconnect retry, this is a same-connection settling + # race, not a slow/dropped connection — timeout stays as given. + assert timeouts_used == [30, 30] + + def test_transient_auth_retry_does_not_clear_cached_ip(self) -> None: + async def _run() -> None: + mgr = _cloned_manager("auth-retry-cache-vm") + mgr._cached_ip = "10.0.0.1" + + async def flaky_ssh(ip, user, pw, cmd, *, timeout): + return (False, "", "Permission denied, please try again.") + + with ( + patch.object(mgr, "get_ip", new=AsyncMock(return_value="10.0.0.1")), + patch("mac2nix.vm.manager.async_ssh_exec", side_effect=flaky_ssh), + patch("mac2nix.vm.manager.asyncio.sleep", new=AsyncMock()), + ): + await mgr.exec_command(["ls"], timeout=30) + + # Unlike a real disconnect, the IP hasn't changed — no reason to + # force a re-lookup. + assert mgr._cached_ip == "10.0.0.1" + + asyncio.run(_run()) + + def test_transient_auth_failure_still_falls_through_to_disconnect_retry_when_persistent(self) -> None: + # If the auth-failure retry also fails, exec_command should simply + # return the failure — it must not also match _is_disconnect and + # trigger a second, different retry path for the same stderr. + call_count = 0 + + async def _run() -> tuple[bool, str, str]: + nonlocal call_count + mgr = _cloned_manager("auth-persistent-vm") + + async def always_denied_ssh(ip, user, pw, cmd, *, timeout): + nonlocal call_count + call_count += 1 + return (False, "", "Permission denied, please try again.") + + with ( + patch.object(mgr, "get_ip", new=AsyncMock(return_value="10.0.0.1")), + patch("mac2nix.vm.manager.async_ssh_exec", side_effect=always_denied_ssh), + patch("mac2nix.vm.manager.asyncio.sleep", new=AsyncMock()), + ): + return await mgr.exec_command(["ls"], timeout=30) + + success, _stdout, stderr = asyncio.run(_run()) + assert success is False + assert call_count == 2 + assert "Permission denied" in stderr + def test_requires_clone(self) -> None: async def _run() -> None: mgr = _make_manager() diff --git a/tests/vm/test_piv_sudo_vm.py b/tests/vm/test_piv_sudo_vm.py index ae886d4..2c47f86 100644 --- a/tests/vm/test_piv_sudo_vm.py +++ b/tests/vm/test_piv_sudo_vm.py @@ -26,7 +26,7 @@ import pytest from mac2nix.generators.scaffold import add_host, init_framework -from mac2nix.vm._utils import VMError, async_run_command +from mac2nix.vm._utils import VMError, async_run_command, is_transient_auth_failure from mac2nix.vm.manager import TartVMManager from mac2nix.vm.validator import Validator @@ -88,10 +88,25 @@ async def _copy_age_key_to_vm(vm: TartVMManager, local_key_path: Path, username: "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", + # PreferredAuthentications/PubkeyAuthentication: see + # async_ssh_exec()'s own comment in _utils.py — without these, ssh + # tries the calling machine's own default identity files first, + # which can exhaust the VM's MaxAuthTries before password auth is + # ever offered. Confirmed as the real, reproducible cause of a + # "Too many authentication failures" failure here, not flakiness. + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", str(local_key_path), f"{vm.vm_user}@{ip}:{remote_dir}/keys.txt", ] returncode, _stdout, stderr = await async_run_command(scp_cmd, timeout=30, env={"SSHPASS": vm.vm_password}) + if returncode != 0 and is_transient_auth_failure(stderr): + # Same boot-settling auth race as TartVMManager.exec_command() — + # confirmed empirically here across real VM runs, not flakiness. + await asyncio.sleep(3) + returncode, _stdout, stderr = await async_run_command(scp_cmd, timeout=30, env={"SSHPASS": vm.vm_password}) if returncode != 0: raise VMError(f"scp age key to VM failed (exit {returncode}): {stderr.strip()}") diff --git a/tests/vm/test_scaffold_vm.py b/tests/vm/test_scaffold_vm.py index 185ea8d..347d75b 100644 --- a/tests/vm/test_scaffold_vm.py +++ b/tests/vm/test_scaffold_vm.py @@ -23,7 +23,7 @@ import pytest from mac2nix.generators.scaffold import add_host, init_framework -from mac2nix.vm._utils import VMError, async_run_command +from mac2nix.vm._utils import VMError, async_run_command, is_transient_auth_failure from mac2nix.vm.manager import TartVMManager from mac2nix.vm.validator import Validator @@ -66,10 +66,27 @@ async def _copy_age_key_to_vm(vm: TartVMManager, local_key_path: Path, username: "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", + # PreferredAuthentications/PubkeyAuthentication: see + # async_ssh_exec()'s own comment in _utils.py — without these, ssh + # tries the calling machine's own default identity files first, + # which can exhaust the VM's MaxAuthTries before password auth is + # ever offered. Confirmed as a real, reproducible failure here + # ("Too many authentication failures"), not mere flakiness — this + # scp call was missing them despite this docstring's own claim to + # mirror Validator._copy_flake_to_vm()'s security pattern in full. + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", str(local_key_path), f"{vm.vm_user}@{ip}:{remote_dir}/keys.txt", ] returncode, _stdout, stderr = await async_run_command(scp_cmd, timeout=30, env={"SSHPASS": vm.vm_password}) + if returncode != 0 and is_transient_auth_failure(stderr): + # Same boot-settling auth race as TartVMManager.exec_command() — + # confirmed empirically here across real VM runs, not flakiness. + await asyncio.sleep(3) + returncode, _stdout, stderr = await async_run_command(scp_cmd, timeout=30, env={"SSHPASS": vm.vm_password}) if returncode != 0: raise VMError(f"scp age key to VM failed (exit {returncode}): {stderr.strip()}") diff --git a/tests/vm/test_validator.py b/tests/vm/test_validator.py index 0ecb30d..9a31460 100644 --- a/tests/vm/test_validator.py +++ b/tests/vm/test_validator.py @@ -425,6 +425,52 @@ async def _run() -> None: asyncio.run(_run()) # Should not raise + def test_scp_transient_auth_failure_retries_and_succeeds(self) -> None: + # scp bypasses exec_command()'s own retry entirely — this proves + # _copy_flake_to_vm has its own equivalent for the same known + # boot-settling auth race (reproduced empirically across real VM runs). + vm = _make_vm(exec_result=(True, "", "")) + call_count = 0 + + async def flaky_run(cmd: list[str], **_kw: object) -> tuple[int, str, str]: + nonlocal call_count + call_count += 1 + if call_count == 1: + return (255, "", "Permission denied, please try again.") + return (0, "", "") + + async def _run() -> None: + v = Validator(vm) + with ( + patch("mac2nix.vm.validator.async_run_command", side_effect=flaky_run), + patch("mac2nix.vm.validator.asyncio.sleep", new=AsyncMock()), + ): + await v._copy_flake_to_vm(Path("/tmp/flake")) + + asyncio.run(_run()) # Should not raise + assert call_count == 2 + + def test_scp_persistent_transient_auth_failure_raises(self) -> None: + vm = _make_vm(exec_result=(True, "", "")) + call_count = 0 + + async def always_denied_run(cmd: list[str], **_kw: object) -> tuple[int, str, str]: + nonlocal call_count + call_count += 1 + return (255, "", "Permission denied, please try again.") + + async def _run() -> None: + v = Validator(vm) + with ( + patch("mac2nix.vm.validator.async_run_command", side_effect=always_denied_run), + patch("mac2nix.vm.validator.asyncio.sleep", new=AsyncMock()), + ): + await v._copy_flake_to_vm(Path("/tmp/flake")) + + with pytest.raises(VMError, match="scp flake"): + asyncio.run(_run()) + assert call_count == 2 # retried exactly once, then gave up + def test_scp_cmd_contains_sshpass(self) -> None: vm = _make_vm(exec_result=(True, "", "")) captured: list[list[str]] = [] diff --git a/tests/vm/test_vm_utils.py b/tests/vm/test_vm_utils.py index 40e31c2..a5b5b78 100644 --- a/tests/vm/test_vm_utils.py +++ b/tests/vm/test_vm_utils.py @@ -15,6 +15,7 @@ async_run_command, async_ssh_exec, is_sshpass_available, + is_transient_auth_failure, ) # --------------------------------------------------------------------------- @@ -74,6 +75,37 @@ def recording_which(name: str) -> str | None: assert calls == ["sshpass"] +# --------------------------------------------------------------------------- +# is_transient_auth_failure() +# --------------------------------------------------------------------------- + + +class TestIsTransientAuthFailure: + def test_matches_permission_denied_please_try_again(self) -> None: + assert is_transient_auth_failure("Permission denied, please try again.") + + def test_matches_case_insensitively(self) -> None: + assert is_transient_auth_failure("PERMISSION DENIED, PLEASE TRY AGAIN.") + + def test_matches_within_larger_stderr_blob(self) -> None: + stderr = ( + "Permission denied, please try again.\r\n" + "admin@192.168.64.4: Permission denied (publickey,password,keyboard-interactive)." + ) + assert is_transient_auth_failure(stderr) + + def test_bare_permission_denied_does_not_match(self) -> None: + # The final, non-retryable summary line lacks "please try again" — + # must not be conflated with the mid-negotiation retry prompt. + assert not is_transient_auth_failure("admin@10.0.0.1: Permission denied (publickey,password).") + + def test_unrelated_stderr_does_not_match(self) -> None: + assert not is_transient_auth_failure("command not found") + + def test_empty_stderr_does_not_match(self) -> None: + assert not is_transient_auth_failure("") + + # --------------------------------------------------------------------------- # async_run_command # --------------------------------------------------------------------------- From 4957fffc68a34d698097e34e4abf73b745d1250b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 13:27:41 -0400 Subject: [PATCH 07/12] fix(vm): pins nixpkgs and fixes jcardsim's FOD determinism nix/piv-emulation/default.nix used `import {}` -- on this machine's Determinate Nix install, 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 `` 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 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. --- nix/piv-emulation/default.nix | 20 +++++++++++++++++++- nix/piv-emulation/jcardsim.nix | 30 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/nix/piv-emulation/default.nix b/nix/piv-emulation/default.nix index b145d7c..09bd232 100644 --- a/nix/piv-emulation/default.nix +++ b/nix/piv-emulation/default.nix @@ -3,7 +3,25 @@ # templates/scaffold/flake.nix). Only ever consumed by this project's own # test suite (tests/vm/test_piv_sudo_vm.py, tests/vm/test_piv_sudo_native.py) # via scripts/provision_piv_emulation.py. -{ pkgs ? import { } }: +# +# nixpkgs is pinned explicitly (not ``) because this project has no +# top-level flake.lock to pin it otherwise, and `` resolves through +# the machine-local Nix registry -- on a Determinate Nix install that's +# `flakehub.com/f/DeterminateSystems/nixpkgs-weekly/*`, a rolling reference +# that changes every week independent of any local state. jcardsim.nix's +# `mvnHash` pins the exact byte content of its Maven-deps fetch, which is +# produced by `maven.buildMavenPackage`'s own nixpkgs-version-dependent +# implementation -- confirmed empirically: a hash verified against one +# week's nixpkgs mismatched a fresh Tart VM's bootstrap of the *same* +# `` reference days later. Pinning here makes that hash (and this +# whole derivation set) actually reproducible across machines and time. +let + pinnedNixpkgs = fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/70ce234312134a463ba7728e94da2486a1d237ac.tar.gz"; + sha256 = "1ify0rml5kx1fggk6hrzc26y98ni445s2kbkfc5x3jpkkagir3jz"; + }; +in +{ pkgs ? import pinnedNixpkgs { } }: { vpcd = pkgs.callPackage ./vpcd.nix { }; jcardsim = pkgs.callPackage ./jcardsim.nix { }; diff --git a/nix/piv-emulation/jcardsim.nix b/nix/piv-emulation/jcardsim.nix index 4b09d07..76db9ba 100644 --- a/nix/piv-emulation/jcardsim.nix +++ b/nix/piv-emulation/jcardsim.nix @@ -77,13 +77,41 @@ maven.buildMavenPackage { # `mvn package` goal too, in non-offline mode -- it hits the exact same # ordering problem, so it needs its own `mvn initialize` pass first, # against its own local repo path ($out/.m2, not $mvnDeps/.m2). + # + # `postBuild` here (a real stdenv phase -- mvnFetchExtraArgs passes + # anything but `env` straight through as derivation attrs, per + # build-maven-package.nix) is required for the FOD to be reproducible at + # all. nixpkgs' own installPhase already deletes *.lastUpdated, + # resolver-status.properties, and _remote.repositories -- but not + # maven-metadata-local.xml, which Maven regenerates (fresh wall-clock + # ) every time it touches a *locally installed* artifact + # (api_classic isn't fetched from any repo). Normalizing it in `preBuild` + # (right after `mvn initialize`) isn't enough: the real `mvn package` step + # that runs after preBuild re-touches the same file while resolving + # api_classic as a compile dependency, re-stamping a fresh timestamp -- + # confirmed empirically by diffing two --check rebuilds under the exact + # same pinned nixpkgs, which still mismatched with only the preBuild-time + # fix in place. `postBuild` runs after that real build and before + # nixpkgs' own installPhase cleanup, so this is the last point that + # actually determines mvnHash below. mvnFetchExtraArgs = { env.JC_CLASSIC_HOME = "${jcClassicHome}"; preBuild = '' mvn initialize -Dmaven.repo.local=$out/.m2 ''; + postBuild = '' + sed -i.bak -E 's#[0-9]+#00000000000000#' \ + $out/.m2/oracle/javacard/api_classic/maven-metadata-local.xml + rm -f $out/.m2/oracle/javacard/api_classic/maven-metadata-local.xml.bak + ''; }; - mvnHash = "sha256-LqPIhjDVFHjohWZXNd8lOgHK7AgRno6hkgByhuLtxzo="; # verified via a real build this session + # Verified against the nixpkgs revision pinned in default.nix, with the + # normalization above in place -- confirmed reproducible via + # a real `nix-store --realise --check` forced rebuild, not just a hash + # that happened to match once. Bump this if default.nix's pinned rev ever + # changes, since maven.buildMavenPackage's own implementation is part of + # nixpkgs and can affect this FOD's exact content. + mvnHash = "sha256-7X2nY1rOa6SJo/YFRb0YbqOoYLktMhM9OTfhFx1wGSE="; # buildMavenPackage has no default installPhase -- the shaded jar # (target/jcardsim-3.0.5-SNAPSHOT.jar, already replaced in place by the From c0f8e12c1137d5d5fae5016d9ec43de74caced9d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 13:28:02 -0400 Subject: [PATCH 08/12] fix(vm): waits for vpcd's listener before starting jcardsim _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. --- scripts/provision_piv_emulation.py | 63 ++++++++++++++++++++++++--- tests/test_provision_piv_emulation.py | 57 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/scripts/provision_piv_emulation.py b/scripts/provision_piv_emulation.py index 7398c49..76e917f 100644 --- a/scripts/provision_piv_emulation.py +++ b/scripts/provision_piv_emulation.py @@ -34,6 +34,7 @@ import argparse import logging import shutil +import socket import subprocess import sys import time @@ -114,7 +115,50 @@ def _install_vpcd_bundle(vpcd_store_path: Path, vendor_id: int, product_id: int) _run(["sudo", "killall", "-SIGKILL", "-m", ".*com.apple.ifdreader"], check=False) -def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.Popen[bytes]: +def _wait_for_vpcd_listener(max_attempts: int = 30, delay_seconds: float = 1.0) -> None: + """Poll until vpcd's TCP listener accepts a connection. + + Killing ifdreader only *asks* launchd to respawn it -- that process still + has to come back up, rediscover the just-replaced bundle, and have the + bundle's own vpcd code bind its listening socket before anything can + connect. jcardsim's VSmartCard is the *client* side of that socket (it + dials out, it never listens -- confirmed via a real local repro: a bare + `java ... VSmartCard` run against a not-yet-ready port raised + `java.net.ConnectException: Connection refused` immediately, which + `_start_jcardsim`'s 2-second poll then misreported as "process exited + immediately", i.e. a build problem it never was). Without this wait, + starting VSmartCard right after the kill is a real, reproducible race, + not flakiness. + """ + last_error: OSError | None = None + for attempt in range(max_attempts): + try: + with socket.create_connection((_JCARDSIM_HOST, _JCARDSIM_VPCD_PORT), timeout=2): + return + except OSError as exc: + last_error = exc + logger.debug("vpcd listener not ready yet (attempt %d/%d): %s", attempt + 1, max_attempts, exc) + time.sleep(delay_seconds) + + # Never gave a real signal to distinguish "driver registration failed" + # (frankmorgner/vsmartcard#303's documented `(null):(null)` entry in + # system_profiler, or "new device skipped" in the CryptoTokenKit log -- + # both real, confirmed macOS failure modes for this exact mechanism) + # from any other cause. Capturing both here so a real failure carries + # its own root cause instead of requiring a separate repro to diagnose. + smartcards = _run(["system_profiler", "SPSmartCardsDataType"], check=False) + ctk_log = _run( + ["log", "show", "--predicate", '(subsystem == "com.apple.CryptoTokenKit")', "--info", "--last", "1m"], + check=False, + ) + raise ProvisioningError( + f"vpcd never started listening on {_JCARDSIM_HOST}:{_JCARDSIM_VPCD_PORT}: {last_error}\n" + f"system_profiler SPSmartCardsDataType:\n{smartcards.stdout}\n" + f"CryptoTokenKit log (last 1m):\n{ctk_log.stdout}" + ) + + +def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.Popen[str]: jcardsim_cfg = Path("jcardsim-mac2nix.cfg") jcardsim_cfg.write_text( f"com.licel.jcardsim.card.applet.0.AID={_PIV_AID}\n" @@ -125,14 +169,20 @@ def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.P classpath = f"{pivapplet_classes}:{jcardsim_jar}" process = subprocess.Popen( # noqa: S603 ["java", "-noverify", "-cp", classpath, "com.licel.jcardsim.remote.VSmartCard", str(jcardsim_cfg)], # noqa: S607 - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, ) time.sleep(2) # let the remote interface bind before anything tries to select the applet if process.poll() is not None: - raise ProvisioningError( - "jcardsim's VSmartCard process exited immediately -- check jcardsim/PivApplet build output" - ) + # Captured, not DEVNULL'd -- a silent crash here previously required a + # separate local repro to diagnose (real incident: jcardsim's + # VSmartCard is a TCP *client* that fails fast with a + # ConnectException if vpcd isn't listening yet, which surfaced only + # as "exited immediately" with no way to tell that apart from an + # actual classpath/build problem). + output = process.stdout.read() if process.stdout else "" + raise ProvisioningError(f"jcardsim's VSmartCard process exited immediately:\n{output}") return process @@ -198,6 +248,7 @@ def provision(vendor_id: int, product_id: int) -> None: pivapplet_path = _nix_build("pivapplet") _install_vpcd_bundle(vpcd_path, vendor_id, product_id) + _wait_for_vpcd_listener() jcardsim_jar_candidates = list((jcardsim_path / "share").glob("**/jcardsim*.jar")) or list( jcardsim_path.glob("**/jcardsim*.jar") diff --git a/tests/test_provision_piv_emulation.py b/tests/test_provision_piv_emulation.py index 34267ef..371b174 100644 --- a/tests/test_provision_piv_emulation.py +++ b/tests/test_provision_piv_emulation.py @@ -69,6 +69,61 @@ def test_returns_process_when_still_running(self, tmp_path: Path, monkeypatch: p assert result is live_process +class TestWaitForVpcdListener: + def test_returns_immediately_once_listener_accepts(self) -> None: + with ( + patch("provision_piv_emulation.socket.create_connection") as mock_connect, + patch("provision_piv_emulation.time.sleep") as mock_sleep, + ): + provision_piv_emulation._wait_for_vpcd_listener(max_attempts=5) + assert mock_connect.call_count == 1 + mock_sleep.assert_not_called() + + def test_retries_on_connection_refused_then_succeeds(self) -> None: + call_count = 0 + + def _flaky_connect(*_args: object, **_kwargs: object) -> MagicMock: + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionRefusedError("Connection refused") + return MagicMock() + + with ( + patch("provision_piv_emulation.socket.create_connection", side_effect=_flaky_connect), + patch("provision_piv_emulation.time.sleep") as mock_sleep, + ): + provision_piv_emulation._wait_for_vpcd_listener(max_attempts=5) + assert call_count == 3 + assert mock_sleep.call_count == 2 + + def test_raises_after_exhausting_attempts(self) -> None: + with ( + patch( + "provision_piv_emulation.socket.create_connection", + side_effect=ConnectionRefusedError("Connection refused"), + ), + patch("provision_piv_emulation.time.sleep"), + patch("provision_piv_emulation._run", return_value=_completed(stdout="diagnostic output")) as mock_run, + pytest.raises(provision_piv_emulation.ProvisioningError, match="never started listening"), + ): + provision_piv_emulation._wait_for_vpcd_listener(max_attempts=3) + commands = [c.args[0][0] for c in mock_run.call_args_list] + assert commands == ["system_profiler", "log"] + + def test_error_includes_diagnostic_output(self) -> None: + with ( + patch( + "provision_piv_emulation.socket.create_connection", + side_effect=ConnectionRefusedError("Connection refused"), + ), + patch("provision_piv_emulation.time.sleep"), + patch("provision_piv_emulation._run", return_value=_completed(stdout="(null):(null) ifd-vpcd.bundle")), + pytest.raises(provision_piv_emulation.ProvisioningError, match=r"\(null\):\(null\)"), + ): + provision_piv_emulation._wait_for_vpcd_listener(max_attempts=3) + + class TestWaitForCard: def test_returns_immediately_once_card_visible(self) -> None: with ( @@ -105,6 +160,7 @@ def _fn(*_args: object, **_kwargs: object) -> object: patch("provision_piv_emulation.shutil.which", return_value="/usr/bin/nix-build"), patch("provision_piv_emulation._nix_build", side_effect=lambda attr: tmp_path / attr), patch("provision_piv_emulation._install_vpcd_bundle", _record("install_vpcd")), + patch("provision_piv_emulation._wait_for_vpcd_listener", _record("wait_for_vpcd_listener")), patch( "provision_piv_emulation._start_jcardsim", MagicMock(side_effect=lambda *_a: (calls.append("start_jcardsim"), process)[1]), @@ -119,6 +175,7 @@ def _fn(*_args: object, **_kwargs: object) -> object: assert calls == [ "install_vpcd", + "wait_for_vpcd_listener", "start_jcardsim", "select_applet", "wait_for_card", From 62775e99b386bd87199fc242ab1c98e6e7b8d360 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 13:47:23 -0400 Subject: [PATCH 09/12] feat(vm): logs every discovered USB candidate on stderr 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. --- scripts/discover_usb_device.py | 15 ++++++++++++++- tests/test_discover_usb_device.py | 12 +++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/discover_usb_device.py b/scripts/discover_usb_device.py index a63cec3..ea8e297 100644 --- a/scripts/discover_usb_device.py +++ b/scripts/discover_usb_device.py @@ -62,9 +62,22 @@ def _find_candidates(ioreg_output: str) -> list[tuple[str, int, int]]: def find_usable_device() -> tuple[int, int] | None: result = subprocess.run(["ioreg", "-p", "IOUSB", "-l"], capture_output=True, text=True, timeout=30, check=False) # noqa: S607 if result.returncode != 0: + print(f"ioreg failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr) # noqa: T201 return None - for name, vendor_id, product_id in _find_candidates(result.stdout): + # Diagnostic only (stderr, never GITHUB_OUTPUT) -- this runner's own USB + # population has no prior precedent, unlike Tart's confirmed-live + # baseline (see this module's own docstring), so every candidate this + # run actually saw needs to be visible in the CI log even when a device + # is "found" -- the first non-excluded candidate is not automatically a + # *usable* one (see hack/PROJECT.md's Task 10 HIDDriverKit finding). + candidates = _find_candidates(result.stdout) + print(f"ioreg found {len(candidates)} USB device candidate(s):", file=sys.stderr) # noqa: T201 + for name, vendor_id, product_id in candidates: + excluded = " [excluded: smartcard-class name]" if _EXCLUDED_NAME_PATTERNS.search(name) else "" + print(f" - {name!r} vendor={vendor_id} product={product_id}{excluded}", file=sys.stderr) # noqa: T201 + + for name, vendor_id, product_id in candidates: if _EXCLUDED_NAME_PATTERNS.search(name): continue return vendor_id, product_id diff --git a/tests/test_discover_usb_device.py b/tests/test_discover_usb_device.py index e7ee3ce..d3666cb 100644 --- a/tests/test_discover_usb_device.py +++ b/tests/test_discover_usb_device.py @@ -57,29 +57,35 @@ def test_no_candidates_in_empty_output(self) -> None: class TestFindUsableDevice: - def test_returns_first_non_smartcard_device(self) -> None: + def test_returns_first_non_smartcard_device(self, capsys) -> None: with patch( "discover_usb_device.subprocess.run", return_value=type("Result", (), {"returncode": 0, "stdout": _REAL_TART_IOREG_EXCERPT, "stderr": ""})(), ): result = discover_usb_device.find_usable_device() assert result == (1452, 33030) # Digitizer appears first in the fixture + # Diagnostic candidate list goes to stderr, never stdout (which feeds GITHUB_OUTPUT). + err = capsys.readouterr().err + assert "Virtual USB Digitizer" in err + assert "Virtual USB Keyboard" in err - def test_excludes_smartcard_class_devices(self) -> None: + def test_excludes_smartcard_class_devices(self, capsys) -> None: with patch( "discover_usb_device.subprocess.run", return_value=type("Result", (), {"returncode": 0, "stdout": _IOREG_WITH_SMARTCARD_READER, "stderr": ""})(), ): result = discover_usb_device.find_usable_device() assert result is None + assert "[excluded" in capsys.readouterr().err - def test_returns_none_when_ioreg_fails(self) -> None: + def test_returns_none_when_ioreg_fails(self, capsys) -> None: with patch( "discover_usb_device.subprocess.run", return_value=type("Result", (), {"returncode": 1, "stdout": "", "stderr": "denied"})(), ): result = discover_usb_device.find_usable_device() assert result is None + assert "denied" in capsys.readouterr().err class TestMain: From 064cdfdfdec5233e1fe223e0caf9913b0dc6b135 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 14:01:34 -0400 Subject: [PATCH 10/12] fix(vm): excludes HID input-class devices from USB discovery 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. --- scripts/discover_usb_device.py | 44 ++++++++++++++++++++++++++----- tests/test_discover_usb_device.py | 33 +++++++++++++++++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/scripts/discover_usb_device.py b/scripts/discover_usb_device.py index ea8e297..2864566 100644 --- a/scripts/discover_usb_device.py +++ b/scripts/discover_usb_device.py @@ -3,16 +3,34 @@ vpcd registers as a macOS smartcard reader driver by spoofing an arbitrary, unrelated USB device's vendor/product ID in its Info.plist (see nix/piv-emulation/vpcd.nix's own docstring for the full mechanism). A Tart -VM guest always has one (confirmed live this session: a synthetic -"Virtual USB Keyboard"), but a real GitHub Actions runner's baseline USB -population has never been checked by anyone -- this script is that check, -run as its own CI step before attempting PIV emulation on a native runner. +VM guest always has a candidate device (confirmed live this session: a +synthetic "Virtual USB Keyboard"/"Virtual USB Digitizer" pair), and a real +GitHub Actions macos-latest runner turned out to expose the identical pair +(same VID/PID) -- but neither is actually *usable* for this trick (see the +HID-exclusion note below). This script is the CI step that checks a given +runner's own USB population and applies that real-world knowledge before +attempting PIV emulation on a native runner. Excludes any device that already self-identifies as smart-card-class hardware, since reusing a real CCID reader's own VID/PID is a confirmed real-world failure mode (frankmorgner/vsmartcard#303) rather than a theoretical one. +Also excludes HID input-class devices (keyboard/mouse/trackpad/digitizer), +confirmed unusable, not merely risky: real diagnostic evidence from both a +live Tart guest and a real GitHub Actions macos-latest runner shows +HIDDriverKit already claims these devices' interfaces before CryptoTokenKit/ +CCID's ifd-vpcd driver can obtain a live reader instance for them -- vpcd's +own bundle registers fine (visible in system_profiler's "Reader Drivers" +list) but "Readers:" stays permanently empty regardless of which HID +device's VID/PID it spoofs (see hack/PROJECT.md's Task 10 entries). Both of +this runner class's only two candidate devices ("Virtual USB Keyboard", +"Virtual USB Digitizer" -- identical VID/PID to Tart's own baseline, which +is why a real GitHub Actions runner is itself a Virtualization.framework +guest) were tried across separate real CI runs and both hit this exact +failure. Excluding them up front means discovery correctly reports "no +usable device" instead of picking one that is provably doomed to fail. + Prints `vendor_id=` and `product_id=` (decimal) to stdout for the first suitable device found, one per line, and exits 0. Exits 1 with no output if no suitable device exists. @@ -31,6 +49,11 @@ # theoretical concern. _EXCLUDED_NAME_PATTERNS = re.compile(r"smart\s*card|ccid|piv|yubikey", re.IGNORECASE) +# Names that indicate a HID input-class device -- confirmed unusable this +# session (see this module's own docstring), not a theoretical concern +# either: HIDDriverKit claims these before CryptoTokenKit/CCID can. +_HID_INPUT_NAME_PATTERNS = re.compile(r"keyboard|mouse|trackpad|touchpad|digitizer|pointing", re.IGNORECASE) + _NAME_FIELD_RE = re.compile(r'"USB Product Name"\s*=\s*"([^"]+)"') _VENDOR_FIELD_RE = re.compile(r'"idVendor"\s*=\s*(\d+)') _PRODUCT_FIELD_RE = re.compile(r'"idProduct"\s*=\s*(\d+)') @@ -74,16 +97,23 @@ def find_usable_device() -> tuple[int, int] | None: candidates = _find_candidates(result.stdout) print(f"ioreg found {len(candidates)} USB device candidate(s):", file=sys.stderr) # noqa: T201 for name, vendor_id, product_id in candidates: - excluded = " [excluded: smartcard-class name]" if _EXCLUDED_NAME_PATTERNS.search(name) else "" - print(f" - {name!r} vendor={vendor_id} product={product_id}{excluded}", file=sys.stderr) # noqa: T201 + print(f" - {name!r} vendor={vendor_id} product={product_id}{_exclusion_reason(name)}", file=sys.stderr) # noqa: T201 for name, vendor_id, product_id in candidates: - if _EXCLUDED_NAME_PATTERNS.search(name): + if _exclusion_reason(name): continue return vendor_id, product_id return None +def _exclusion_reason(name: str) -> str: + if _EXCLUDED_NAME_PATTERNS.search(name): + return " [excluded: smartcard-class name]" + if _HID_INPUT_NAME_PATTERNS.search(name): + return " [excluded: HID input-class device, confirmed unusable -- HIDDriverKit claims it first]" + return "" + + def main() -> int: device = find_usable_device() if device is None: diff --git a/tests/test_discover_usb_device.py b/tests/test_discover_usb_device.py index d3666cb..094dab2 100644 --- a/tests/test_discover_usb_device.py +++ b/tests/test_discover_usb_device.py @@ -45,6 +45,25 @@ } """ +# Synthetic (not a real capture, unlike the fixture above) -- exists only to +# prove a genuinely non-HID, non-smartcard candidate still gets picked once +# HID input-class devices are excluded ahead of it. +_IOREG_WITH_NON_HID_DEVICE = """ ++-o Virtual USB Keyboard@0e900000 + | { + | "idProduct" = 33029 + | "USB Product Name" = "Virtual USB Keyboard" + | "idVendor" = 1452 + | } + | + +-o USB-C to Ethernet Adapter@0ea00000 + { + "idProduct" = 512 + "USB Product Name" = "USB-C to Ethernet Adapter" + "idVendor" = 1452 + } +""" + class TestFindCandidates: def test_parses_real_tart_ioreg_output(self) -> None: @@ -57,17 +76,27 @@ def test_no_candidates_in_empty_output(self) -> None: class TestFindUsableDevice: - def test_returns_first_non_smartcard_device(self, capsys) -> None: + def test_excludes_hid_input_devices(self, capsys) -> None: + """Both of Tart's (and, confirmed this session, a real GHA runner's) only candidates are HID input devices.""" with patch( "discover_usb_device.subprocess.run", return_value=type("Result", (), {"returncode": 0, "stdout": _REAL_TART_IOREG_EXCERPT, "stderr": ""})(), ): result = discover_usb_device.find_usable_device() - assert result == (1452, 33030) # Digitizer appears first in the fixture + assert result is None # Diagnostic candidate list goes to stderr, never stdout (which feeds GITHUB_OUTPUT). err = capsys.readouterr().err assert "Virtual USB Digitizer" in err assert "Virtual USB Keyboard" in err + assert err.count("[excluded: HID input-class device") == 2 + + def test_returns_first_non_hid_non_smartcard_device(self) -> None: + with patch( + "discover_usb_device.subprocess.run", + return_value=type("Result", (), {"returncode": 0, "stdout": _IOREG_WITH_NON_HID_DEVICE, "stderr": ""})(), + ): + result = discover_usb_device.find_usable_device() + assert result == (1452, 512) # the Ethernet adapter, not the excluded keyboard def test_excludes_smartcard_class_devices(self, capsys) -> None: with patch( From 2b0591c26e72f79c8c652753fbeddfd3fc7b6d96 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 20:03:06 -0400 Subject: [PATCH 11/12] fix(vm): registers vpcd via self-hosted pcscd, not CryptoTokenKit 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. --- nix/piv-emulation/default.nix | 14 ++ nix/piv-emulation/pcsc-stack.nix | 72 +++++++ nix/piv-emulation/vpcd.nix | 53 +++-- scripts/provision_piv_emulation.py | 275 +++++++++++++++----------- tests/test_provision_piv_emulation.py | 178 ++++++++++++----- 5 files changed, 413 insertions(+), 179 deletions(-) create mode 100644 nix/piv-emulation/pcsc-stack.nix diff --git a/nix/piv-emulation/default.nix b/nix/piv-emulation/default.nix index 09bd232..242ac23 100644 --- a/nix/piv-emulation/default.nix +++ b/nix/piv-emulation/default.nix @@ -22,8 +22,22 @@ let }; in { pkgs ? import pinnedNixpkgs { } }: +let + pcscStack = import ./pcsc-stack.nix { inherit pkgs; }; +in { vpcd = pkgs.callPackage ./vpcd.nix { }; jcardsim = pkgs.callPackage ./jcardsim.nix { }; pivapplet = pkgs.callPackage ./pivapplet.nix { }; + inherit (pcscStack) pcsclite opensc yubicoPivTool; + + # A JRE to run jcardsim's compiled VSmartCard with. jcardsim.nix itself + # only needs jdk8 to *compile* (its pom.xml targets Java 1.7), but that's a + # build-time-only dependency, not something the built jar carries with it + # -- confirmed by a real failure: a Tart VM never has `java` on PATH at + # all otherwise ("Unable to locate a Java Runtime"), a bug never caught + # before because provisioning always failed earlier at VID/PID + # registration. Any modern JRE runs jcardsim's old-bytecode jar fine (JVMs + # are backwards compatible) -- this is just nixpkgs' current default JDK. + jdk = pkgs.jdk; } diff --git a/nix/piv-emulation/pcsc-stack.nix b/nix/piv-emulation/pcsc-stack.nix new file mode 100644 index 0000000..d7467eb --- /dev/null +++ b/nix/piv-emulation/pcsc-stack.nix @@ -0,0 +1,72 @@ +# A self-hosted PC/SC stack (pcsclite + opensc + yubico-piv-tool) that never +# touches macOS's own proprietary PCSC/CryptoTokenKit service. +# +# Why this exists: nixpkgs' stock `opensc` and `yubico-piv-tool` both default +# to linking Apple's `PCSC.framework` on Darwin (`lib.optional +# (!stdenv.hostPlatform.isDarwin) pcsclite` in opensc's package.nix; +# `withApplePCSC ? stdenv.hostPlatform.isDarwin` in yubico-piv-tool's) -- +# correct for a real Mac with a real YubiKey, but Apple's daemon is exactly +# the thing vpcd.nix's own comment documents as a dead end for emulated +# cards on Tart (HIDDriverKit already claims Tart's only synthetic USB +# devices, so ifdreader never gets a live reader for vpcd's bundle no matter +# how it's registered). Overriding both packages to link nixpkgs' own +# pcsclite instead routes them through a pcscd we run ourselves +# (scripts/provision_piv_emulation.py), which loads vpcd via a plain +# reader.conf entry -- no USB device, real or synthetic, required at all. +# Verified end-to-end on real hardware this session: pcscd sees the reader, +# jcardsim/PivApplet present a real ATR, and PKCS#11 login+sign+verify all +# succeed through this exact stack. +# +# This ONLY affects the test harness. The scaffold template +# (templates/scaffold/modules/darwin/security.nix) that real users get +# always references stock `pkgs.opensc` -- correct for their real hardware, +# where Apple's PCSC.framework is exactly the right thing to talk to a real +# YubiKey. tests/vm/test_piv_sudo_vm.py swaps `pkgs.opensc` for this +# derivation's `opensc` via a `nixpkgs.overlays` entry injected into the +# *generated test scaffold's own* configuration.nix, never into the +# template itself. +# +# ipcdir/usbdropdir/serialconfdir are all left at nixpkgs' own defaults +# (ipcdir=/run/pcscd) -- macOS has no /run by default, but +# provision_piv_emulation.py creates /run/pcscd with sudo before starting +# pcscd (the whole script already runs as root). This was previously done +# via a custom -Dipcdir= mesonFlags override pointed at a project-relative +# scratch directory; that path was 104 bytes long, exactly AF_UNIX's +# sun_path limit on Darwin, and the socket bind silently truncated to the +# wrong filename with no error -- confirmed by direct reproduction. Using +# the real, short /run/pcscd path removes an entire derivation-override +# axis and the failure mode that came with it. +{ + pkgs, +}: +let + # nixpkgs' pcsclite postPatch hardcodes the Linux-style + # "libpcsclite_real.so.1" name for the libredirect shim's dlopen target. + # On Darwin, libtool actually produces "libpcsclite_real.1.dylib" + # (version before the extension, not after) -- confirmed via a real build + # (`find $out -name 'libpcsclite_real*'`). Without this fix, every + # pcsclite client (opensc, yubico-piv-tool, pcscd itself) fails to dlopen + # the real implementation on Darwin specifically, a real upstream nixpkgs + # bug independent of anything else in this file. + pcsclite = pkgs.pcsclite.overrideAttrs (old: { + postPatch = builtins.replaceStrings + [ ''"$lib/lib/libpcsclite_real.so.1"'' ] + [ ''"$lib/lib/libpcsclite_real.1.dylib"'' ] + old.postPatch; + }); + + opensc = pkgs.opensc.overrideAttrs (old: { + buildInputs = old.buildInputs ++ [ pcsclite ]; + configureFlags = old.configureFlags ++ [ + "--with-pcsc-provider=${pkgs.lib.getLib pcsclite}/lib/libpcsclite${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}" + ]; + }); + + yubicoPivTool = pkgs.yubico-piv-tool.override { + withApplePCSC = false; + inherit pcsclite; + }; +in +{ + inherit pcsclite opensc yubicoPivTool; +} diff --git a/nix/piv-emulation/vpcd.nix b/nix/piv-emulation/vpcd.nix index 4ab6641..dd15973 100644 --- a/nix/piv-emulation/vpcd.nix +++ b/nix/piv-emulation/vpcd.nix @@ -1,32 +1,51 @@ # Builds vpcd (frankmorgner/vsmartcard's `virtualsmartcard` component, GPLv3, # confirmed via its COPYING file) -- the virtual PC/SC reader that lets a # software-only PIV card emulator (jcardsim.nix + pivapplet.nix) register -# with macOS's own smartcard stack. +# as a smartcard reader. # # Standalone process, invoked independently -- never linked into mac2nix's # own Python/Nix code -- so this falls under GPLv3's mere-aggregation # allowance. Do not vendor its compiled output into anything this project # ships to end users. # -# Configured to match the real, official `make osx` build target's own -# recipe (virtualsmartcard/MacOSX/Makefile.am): --enable-infoplist plus -# pointing --enable-serialdropdir/--enable-serialconfdir directly at a -# bundle-shaped path under $out, which assembles a real -# ifd-vpcd.bundle/Contents/{MacOS,Info.plist} layout purely through those -# install-path choices -- there is no separate "bundle template" mechanism. +# Still built with --enable-infoplist -- but no longer for macOS's own +# *proprietary* CryptoTokenKit/ifdreader PCSC service, which requires +# spoofing a real (or synthetic) USB device's VID/PID in the bundle's +# Info.plist so the daemon's IOKit-based hotplug matching fires. That route +# is a confirmed dead end on Tart specifically: Tart's only two synthetic +# USB devices (keyboard, digitizer) are already claimed by macOS's own +# HIDDriverKit stack, so ifdreader registers the driver bundle but never +# gets a live reader instance for it (real diagnostic evidence: +# system_profiler SPSmartCardsDataType shows the driver registered, +# `Readers:` stays empty). +# +# --enable-infoplist is kept purely because pcsclite's own macOS dynamic +# loader (dyn_macosx.c) calls CFBundleCreate() on whatever LIBPATH a +# reader.conf entry gives it, which requires an actual .bundle directory +# (Info.plist + MacOS/) -- a bare .dylib fails to load +# ("RFLoadReader failed: 0x80100014", confirmed empirically). The bundle's +# ifdVendorID/ifdProductID fields go unused and are never patched: instead, +# scripts/provision_piv_emulation.py hand-writes a `/etc/reader.conf.d/vpcd` +# entry whose LIBPATH points directly at this built bundle -- vsmartcard's +# own upstream install_readerconf target (Makefile.am, selected when +# --enable-infoplist is *not* passed) documents exactly this reader.conf +# mechanism, just installing a bare .so instead of a bundle, which is why +# it can't be used as-is here. PCSC-lite treats reader.conf.d entries as +# "serial" (non-hotplug) readers that are always available, independent of +# any real or virtual USB device -- there is no VID/PID to spoof and +# nothing for a HID driver to compete for. +# +# Registers against a *self-hosted* nixpkgs pcscd (nix/piv-emulation/ +# pcsc-stack.nix), never Apple's proprietary daemon -- verified end-to-end +# on real hardware this session: pcscd loads this exact bundle via +# reader.conf, jcardsim/PivApplet present a real ATR through it, and +# PKCS#11 login+sign+verify all succeed. +# # Uses nixpkgs' own pcsclite for the ifdhandler.h/wintypes.h/reader.h headers # (pkg-config discoverable, per configure.ac's default libpcsclite=no path) # rather than Apple's proprietary PCSC.framework via an ambient `xcode-select` -# lookup -- keeps the build hermetic, and is proven ABI-compatible with -# macOS's real SmartCardServices daemon: Apple's own shipped CCID driver -# (ifd-ccid.bundle) is itself built against this exact same portable -# PC/SC IFD-handler API. -# -# The resulting bundle's ifdVendorID/ifdProductID must be patched by the -# provisioning script (scripts/provision_piv_emulation.py) with a -# caller-supplied VID/PID *after* this builds, not baked in here -- the VM -# and native-runner execution contexts use different, discovered-not-assumed -# target devices. +# lookup -- keeps the build hermetic, and is the same IFD-handler API the +# self-hosted pcsc-stack.nix pcscd actually loads it with at runtime. { fetchFromGitHub, stdenv, diff --git a/scripts/provision_piv_emulation.py b/scripts/provision_piv_emulation.py index 76e917f..d55f2d0 100644 --- a/scripts/provision_piv_emulation.py +++ b/scripts/provision_piv_emulation.py @@ -1,38 +1,56 @@ -"""Register a virtual PIV card with macOS's smartcard stack, for real E2E sudo/PAM testing. +"""Register a virtual PIV card with a self-hosted PC/SC stack, for real E2E sudo/PAM testing. Runs *locally* on whatever machine needs the virtual card (a Tart VM guest, or a native CI runner) -- it is not a remote-orchestration script like scripts/prewarm_vm.py, so it uses plain synchronous subprocess calls. +Never touches macOS's own proprietary CryptoTokenKit/ifdreader PCSC service. +That daemon only accepts drivers that present as USB CCID devices (spoofing +a real or synthetic device's VID/PID in the driver bundle's Info.plist), and +on Tart specifically this is a confirmed dead end: Tart's only two synthetic +USB devices (keyboard, digitizer) are already claimed by macOS's own +HIDDriverKit stack, so ifdreader registers the driver bundle but never gets +a live reader instance for it. Instead this runs a *self-hosted* pcscd +(nix/piv-emulation/pcsc-stack.nix) that honors vpcd's own documented +reader.conf.d registration -- a static, non-hotplug reader entry with no +VID/PID and no USB device involved at all (see vpcd.nix and pcsc-stack.nix's +own comments for the full rationale). Verified end-to-end on real hardware: +pcscd sees the reader, jcardsim/PivApplet present a real ATR through it, and +PKCS#11 login+sign+verify all succeed. + Orchestrates, in order (see hack/plans/fix-vm-tahoe-base-image-1785337468-migration-mvp.md's Task 10 Step 3 for the full research trail behind each choice): -1. Build vpcd/jcardsim/pivapplet via the local Nix derivations in - nix/piv-emulation/ (never vendored binaries -- see PROJECT.md). -2. Start jcardsim's VSmartCard remote interface with a PivApplet-configured - jcardsim.cfg. -3. Select the applet via its AID. -4. Copy vpcd's built ifd-vpcd.bundle to the real system driver directory and - patch its Info.plist with the caller-supplied vendor/product ID -- the - Nix store copy is read-only, so patching happens on a real filesystem - copy, never in the store. Restart the driver host (not a full reboot). -5. Poll `system_profiler SPSmartCardsDataType` until the emulated card - appears (bounded retry -- this is a hard failure if it never appears, - not a soft skip; see Task 10 Step 4's own no-skip contract). -6. Provision the card's PIV slot 9a via yubico-piv-tool (a fresh card has +1. Build vpcd/pcsclite/opensc/yubicoPivTool/jcardsim/pivapplet via the local + Nix derivations in nix/piv-emulation/ (never vendored binaries -- see + PROJECT.md). +2. Register vpcd as a static PC/SC reader via /etc/reader.conf.d/vpcd. +3. Start our own pcscd as a detached background daemon. +4. Start jcardsim's VSmartCard remote interface with a PivApplet-configured + jcardsim.cfg, also detached -- both it and pcscd stay running after this + script exits, since the PAM authentication step that needs them runs + afterward, in a separate SSH call. +5. Select the applet via its full AID (yubico-piv-tool's own SELECT only + sends the 5-byte PIV RID, which jcardsim/PivApplet reject as an unknown + applet unless the full AID has already been selected once). +6. Poll our own opensc-tool until the emulated card appears (bounded retry + -- this is a hard failure if it never appears, not a soft skip; see Task + 10 Step 4's own no-skip contract). +7. Provision the card's PIV slot 9a via yubico-piv-tool (a fresh card has no usable keys -- arekinath/PivApplet#23's most-cited bug report was exactly this being mistaken for a broken emulator). -7. Export the freshly-generated certificate into +8. Export the freshly-generated certificate into ~/.eid/authorized_certificates -- the automated equivalent of docs/runbooks/yubikey-piv.md's own manual cert-export step. -Usage: ``uv run python scripts/provision_piv_emulation.py --vendor-id 1452 --product-id 33029`` +Usage: ``uv run python scripts/provision_piv_emulation.py`` """ from __future__ import annotations import argparse import logging +import re import shutil import socket import subprocess @@ -45,7 +63,16 @@ _PIV_AID = "A000000308000010000100" _SELECT_APPLET_APDU = "80 b8 00 00 12 0b a0 00 00 03 08 00 00 10 00 01 00 05 00 00 02 0F 0F 7f" _DEFAULT_PIN = "123456" -_DRIVER_DEST = Path("/usr/local/libexec/SmartCardServices/drivers/ifd-vpcd.bundle") + +# yubico-piv-tool's own `-r` default is "Yubikey" -- it would never match +# vpcd's "Virtual PCD ..." reader name. Confirmed real bug: this was never +# caught before because provisioning always failed earlier (at the old +# VID/PID registration step) before yubico-piv-tool ever ran against an +# emulated card. +_READER_NAME_FILTER = "Virtual" + +_READER_CONF_PATH = Path("/etc/reader.conf.d/vpcd") +_PCSCD_IPC_DIR = Path("/run/pcscd") _JCARDSIM_HOST = "127.0.0.1" _JCARDSIM_VPCD_PORT = 35963 @@ -76,59 +103,66 @@ def _nix_build(attr: str) -> Path: return Path(result.stdout.strip()) -def _install_vpcd_bundle(vpcd_store_path: Path, vendor_id: int, product_id: int) -> None: - if _DRIVER_DEST.exists(): - _run(["sudo", "rm", "-rf", str(_DRIVER_DEST)]) - _run(["sudo", "mkdir", "-p", str(_DRIVER_DEST.parent)]) - _run(["sudo", "cp", "-R", str(vpcd_store_path / "ifd-vpcd.bundle"), str(_DRIVER_DEST.parent)]) +def _write_reader_conf(vpcd_bundle: Path) -> None: + """Register vpcd as an always-available, non-hotplug PC/SC reader. - info_plist = _DRIVER_DEST / "Contents" / "Info.plist" - # Info.plist stores these as single-element arrays of hex strings - # (verified against a real build this session -- ["0x18d1"] style, not - # a bare string) -- plutil's -json replace matches that shape exactly. - _run( - [ - "sudo", - "plutil", - "-replace", - "ifdVendorID", - "-json", - f'["0x{vendor_id:04x}"]', - str(info_plist), - ] - ) - _run( - [ - "sudo", - "plutil", - "-replace", - "ifdProductID", - "-json", - f'["0x{product_id:04x}"]', - str(info_plist), - ] + No VID/PID and no USB device involved -- pcsclite treats reader.conf.d + entries as static "serial" readers (see vpcd.nix's own comment for the + full rationale). This is the whole reason macOS's proprietary + CryptoTokenKit/ifdreader VID/PID-spoofing mechanism -- and its + Tart-specific HID-claim dead end -- never comes into play at all. + """ + reader_conf = ( + f'FRIENDLYNAME "Virtual PCD"\nDEVICENAME /dev/null:0x8C7B\nLIBPATH {vpcd_bundle}\nCHANNELID 0x8C7B\n' ) - - # Not a full reboot -- vsmartcard's own docs and a 2025 real-world - # resolution (frankmorgner/vsmartcard#303) confirm a driver-daemon - # restart is sufficient. - _run(["sudo", "killall", "-SIGKILL", "-m", ".*com.apple.ifdreader"], check=False) + tmp_conf = Path("vpcd.reader.conf") + tmp_conf.write_text(reader_conf) + _run(["sudo", "mkdir", "-p", str(_READER_CONF_PATH.parent)]) + _run(["sudo", "cp", str(tmp_conf), str(_READER_CONF_PATH)]) + + +def _start_pcscd(pcscd_bin: Path) -> subprocess.Popen[str]: + """Start our own pcscd as a detached background daemon. + + Never Apple's proprietary daemon -- this pcscd (nixpkgs' own build) + honors reader.conf.d's static reader registration, which is what lets + vpcd register without a real or virtual USB device at all. `-f` keeps + logs on stdout (captured to pcscd.log here) instead of syslog, so a + failure carries its own diagnostics; `start_new_session=True` detaches + it from this SSH session so it survives past this script's own exit -- + the pamtester authentication step that needs it runs afterward, in a + separate SSH call. + """ + _run(["sudo", "mkdir", "-p", str(_PCSCD_IPC_DIR)], check=False) + log_path = Path("pcscd.log") + with log_path.open("w") as log_file: + process = subprocess.Popen( # noqa: S603 + ["sudo", str(pcscd_bin), "-f", "-d"], # noqa: S607 + stdout=log_file, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + start_new_session=True, + ) + time.sleep(1) + if process.poll() is not None: + raise ProvisioningError(f"pcscd exited immediately:\n{log_path.read_text()}") + return process def _wait_for_vpcd_listener(max_attempts: int = 30, delay_seconds: float = 1.0) -> None: """Poll until vpcd's TCP listener accepts a connection. - Killing ifdreader only *asks* launchd to respawn it -- that process still - has to come back up, rediscover the just-replaced bundle, and have the - bundle's own vpcd code bind its listening socket before anything can - connect. jcardsim's VSmartCard is the *client* side of that socket (it - dials out, it never listens -- confirmed via a real local repro: a bare - `java ... VSmartCard` run against a not-yet-ready port raised - `java.net.ConnectException: Connection refused` immediately, which - `_start_jcardsim`'s 2-second poll then misreported as "process exited - immediately", i.e. a build problem it never was). Without this wait, - starting VSmartCard right after the kill is a real, reproducible race, - not flakiness. + pcscd loads vpcd's driver synchronously during its own startup (real, + observed behavior: "IFDHCreateChannel() Waiting for virtual ICC" appears + in pcscd's own log within milliseconds of "daemon ready") -- but this + script's own process starting pcscd doesn't guarantee that sequence has + finished by the time control returns here. jcardsim's VSmartCard is the + TCP *client* side of that socket (it dials out, it never listens -- + confirmed via a real local repro: a bare `java ... VSmartCard` run + against a not-yet-ready port raised `java.net.ConnectException: + Connection refused` immediately). Without this wait, starting VSmartCard + too early is a real, reproducible race, not flakiness. """ last_error: OSError | None = None for attempt in range(max_attempts): @@ -140,25 +174,17 @@ def _wait_for_vpcd_listener(max_attempts: int = 30, delay_seconds: float = 1.0) logger.debug("vpcd listener not ready yet (attempt %d/%d): %s", attempt + 1, max_attempts, exc) time.sleep(delay_seconds) - # Never gave a real signal to distinguish "driver registration failed" - # (frankmorgner/vsmartcard#303's documented `(null):(null)` entry in - # system_profiler, or "new device skipped" in the CryptoTokenKit log -- - # both real, confirmed macOS failure modes for this exact mechanism) - # from any other cause. Capturing both here so a real failure carries - # its own root cause instead of requiring a separate repro to diagnose. - smartcards = _run(["system_profiler", "SPSmartCardsDataType"], check=False) - ctk_log = _run( - ["log", "show", "--predicate", '(subsystem == "com.apple.CryptoTokenKit")', "--info", "--last", "1m"], - check=False, - ) + pcscd_log = Path("pcscd.log") + pcscd_log_contents = pcscd_log.read_text() if pcscd_log.exists() else "(no pcscd.log found)" + reader_conf = _run(["sudo", "cat", str(_READER_CONF_PATH)], check=False) raise ProvisioningError( f"vpcd never started listening on {_JCARDSIM_HOST}:{_JCARDSIM_VPCD_PORT}: {last_error}\n" - f"system_profiler SPSmartCardsDataType:\n{smartcards.stdout}\n" - f"CryptoTokenKit log (last 1m):\n{ctk_log.stdout}" + f"{_READER_CONF_PATH}:\n{reader_conf.stdout}\n" + f"pcscd log:\n{pcscd_log_contents}" ) -def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.Popen[str]: +def _start_jcardsim(jdk_path: Path, jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.Popen[str]: jcardsim_cfg = Path("jcardsim-mac2nix.cfg") jcardsim_cfg.write_text( f"com.licel.jcardsim.card.applet.0.AID={_PIV_AID}\n" @@ -167,48 +193,59 @@ def _start_jcardsim(jcardsim_jar: Path, pivapplet_classes: Path) -> subprocess.P f"com.licel.jcardsim.vsmartcard.port={_JCARDSIM_VPCD_PORT}\n" ) classpath = f"{pivapplet_classes}:{jcardsim_jar}" - process = subprocess.Popen( # noqa: S603 - ["java", "-noverify", "-cp", classpath, "com.licel.jcardsim.remote.VSmartCard", str(jcardsim_cfg)], # noqa: S607 - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) + log_path = Path("jcardsim.log") + # Captured to a real file (not a pipe) and detached via + # start_new_session=True -- this process must survive past this + # script's own exit for the same reason pcscd does (see _start_pcscd). + # A silent crash here previously required a separate local repro to + # diagnose (real incident: jcardsim's VSmartCard is a TCP *client* that + # fails fast with a ConnectException if vpcd isn't listening yet, which + # a bare poll then misreported as "process exited immediately", i.e. a + # build problem it never was). + java_bin = str(jdk_path / "bin" / "java") + with log_path.open("w") as log_file: + process = subprocess.Popen( # noqa: S603 + [java_bin, "-noverify", "-cp", classpath, "com.licel.jcardsim.remote.VSmartCard", str(jcardsim_cfg)], + stdout=log_file, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + start_new_session=True, + ) time.sleep(2) # let the remote interface bind before anything tries to select the applet if process.poll() is not None: - # Captured, not DEVNULL'd -- a silent crash here previously required a - # separate local repro to diagnose (real incident: jcardsim's - # VSmartCard is a TCP *client* that fails fast with a - # ConnectException if vpcd isn't listening yet, which surfaced only - # as "exited immediately" with no way to tell that apart from an - # actual classpath/build problem). - output = process.stdout.read() if process.stdout else "" - raise ProvisioningError(f"jcardsim's VSmartCard process exited immediately:\n{output}") + raise ProvisioningError(f"jcardsim's VSmartCard process exited immediately:\n{log_path.read_text()}") return process -def _select_applet(reader_pattern: str = "Virtual PCD 00 00") -> None: - _run(["opensc-tool", "-r", reader_pattern, "-s", _SELECT_APPLET_APDU]) +def _select_applet(opensc_path: Path, reader_pattern: str = "Virtual PCD 00 00") -> None: + opensc_tool = opensc_path / "bin" / "opensc-tool" + _run([str(opensc_tool), "-r", reader_pattern, "-s", _SELECT_APPLET_APDU]) -def _wait_for_card(max_attempts: int = 10, delay_seconds: int = 3) -> None: +def _wait_for_card(opensc_path: Path, max_attempts: int = 10, delay_seconds: int = 3) -> None: + opensc_tool = opensc_path / "bin" / "opensc-tool" for attempt in range(max_attempts): - result = _run(["system_profiler", "SPSmartCardsDataType"], check=False) - if "Virtual PCD" in result.stdout or "PIV" in result.stdout: + result = _run([str(opensc_tool), "--list-readers"], check=False) + if re.search(r"Yes\s+Virtual PCD", result.stdout): return logger.debug("Card not yet visible (attempt %d/%d)", attempt + 1, max_attempts) time.sleep(delay_seconds) - raise ProvisioningError(f"Emulated PIV card did not appear in system_profiler after {max_attempts} attempts") + raise ProvisioningError(f"Emulated PIV card did not appear after {max_attempts} attempts") -def _provision_piv_slot() -> None: +def _provision_piv_slot(yubico_piv_tool_path: Path) -> None: # A freshly-started card has no usable keys -- arekinath/PivApplet#23's # most-cited bug report was exactly this being mistaken for a broken # emulator. Slot 9a, RSA (yubico-piv-tool's default), matching the # pre-built PivApplet .cap's own RSA/EC/AES/3DES feature set. - _run(["yubico-piv-tool", "-a", "generate", "-s", "9a", "-o", "pubkey.pem"]) + yubico_piv_tool = str(yubico_piv_tool_path / "bin" / "yubico-piv-tool") + _run([yubico_piv_tool, "-r", _READER_NAME_FILTER, "-a", "generate", "-s", "9a", "-o", "pubkey.pem"]) _run( [ - "yubico-piv-tool", + yubico_piv_tool, + "-r", + _READER_NAME_FILTER, "-a", "verify-pin", "-P", @@ -225,7 +262,7 @@ def _provision_piv_slot() -> None: "cert.pem", ] ) - _run(["yubico-piv-tool", "-a", "import-certificate", "-s", "9a", "-i", "cert.pem"]) + _run([yubico_piv_tool, "-r", _READER_NAME_FILTER, "-a", "import-certificate", "-s", "9a", "-i", "cert.pem"]) def _export_certificate() -> None: @@ -238,16 +275,21 @@ def _export_certificate() -> None: authorized.chmod(0o644) -def provision(vendor_id: int, product_id: int) -> None: +def provision() -> None: """Run the full provisioning sequence. Raises ProvisioningError on any failure.""" if shutil.which("nix-build") is None: - raise ProvisioningError("nix-build is not on PATH -- required to build vpcd/jcardsim/pivapplet") + raise ProvisioningError("nix-build is not on PATH -- required to build the PIV emulation stack") vpcd_path = _nix_build("vpcd") + pcsclite_path = _nix_build("pcsclite") + opensc_path = _nix_build("opensc") + yubico_piv_tool_path = _nix_build("yubicoPivTool") + jdk_path = _nix_build("jdk") jcardsim_path = _nix_build("jcardsim") pivapplet_path = _nix_build("pivapplet") - _install_vpcd_bundle(vpcd_path, vendor_id, product_id) + _write_reader_conf(vpcd_path / "ifd-vpcd.bundle") + _start_pcscd(pcsclite_path / "bin" / "pcscd") _wait_for_vpcd_listener() jcardsim_jar_candidates = list((jcardsim_path / "share").glob("**/jcardsim*.jar")) or list( @@ -256,25 +298,26 @@ def provision(vendor_id: int, product_id: int) -> None: if not jcardsim_jar_candidates: raise ProvisioningError(f"No jcardsim jar found under {jcardsim_path}") - process = _start_jcardsim(jcardsim_jar_candidates[0], pivapplet_path) - try: - _select_applet() - _wait_for_card() - _provision_piv_slot() - _export_certificate() - finally: - process.terminate() + # pcscd and jcardsim are deliberately left running (not terminated) -- + # both need to stay alive for the PAM authentication step that runs + # afterward, in a separate SSH call. Leaving them running on a + # provisioning failure too is also the right call here: it preserves + # live diagnostic state instead of tearing it down before anyone can + # inspect it. + _start_jcardsim(jdk_path, jcardsim_jar_candidates[0], pivapplet_path) + _select_applet(opensc_path) + _wait_for_card(opensc_path) + _provision_piv_slot(yubico_piv_tool_path) + _export_certificate() def main() -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--vendor-id", type=int, required=True, help="USB vendor ID (decimal) to spoof for vpcd") - parser.add_argument("--product-id", type=int, required=True, help="USB product ID (decimal) to spoof for vpcd") - args = parser.parse_args() + parser.parse_args() try: - provision(args.vendor_id, args.product_id) + provision() except ProvisioningError as exc: logger.error("PIV emulation provisioning failed: %s", exc) return 1 diff --git a/tests/test_provision_piv_emulation.py b/tests/test_provision_piv_emulation.py index 371b174..3683fdf 100644 --- a/tests/test_provision_piv_emulation.py +++ b/tests/test_provision_piv_emulation.py @@ -19,30 +19,65 @@ def _completed(stdout: str = "", returncode: int = 0) -> subprocess.CompletedPro return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="") -class TestInstallVpcdBundle: - def test_patches_info_plist_with_hex_vendor_and_product_id(self, tmp_path: Path) -> None: - with ( - patch("provision_piv_emulation._DRIVER_DEST", tmp_path / "ifd-vpcd.bundle"), - patch("provision_piv_emulation._run", return_value=_completed()) as mock_run, - ): - provision_piv_emulation._install_vpcd_bundle(tmp_path / "store-path", vendor_id=1452, product_id=33029) +class TestWriteReaderConf: + def test_writes_libpath_pointing_at_the_bundle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + vpcd_bundle = tmp_path / "store-path" / "ifd-vpcd.bundle" + with patch("provision_piv_emulation._run", return_value=_completed()) as mock_run: + provision_piv_emulation._write_reader_conf(vpcd_bundle) + + written_conf = (tmp_path / "vpcd.reader.conf").read_text() + assert f"LIBPATH {vpcd_bundle}" in written_conf + assert "FRIENDLYNAME" in written_conf + # No VID/PID anywhere -- the whole point of registering via + # reader.conf instead of macOS's Info.plist/USB-hotplug mechanism. + assert "VendorID" not in written_conf + assert "ProductID" not in written_conf + + calls = [c.args[0] for c in mock_run.call_args_list] + assert calls[0][:3] == ["sudo", "mkdir", "-p"] + assert calls[1][:2] == ["sudo", "cp"] + + def test_writes_to_real_reader_conf_d_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + with patch("provision_piv_emulation._run", return_value=_completed()) as mock_run: + provision_piv_emulation._write_reader_conf(tmp_path / "ifd-vpcd.bundle") calls = [c.args[0] for c in mock_run.call_args_list] - plutil_calls = [c for c in calls if "plutil" in c] - assert any('["0x05ac"]' in " ".join(c) for c in plutil_calls), plutil_calls - assert any('["0x8105"]' in " ".join(c) for c in plutil_calls), plutil_calls + assert str(provision_piv_emulation._READER_CONF_PATH) in calls[1] + + +class TestStartPcscd: + def test_raises_if_process_exits_immediately(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + dead_process = MagicMock() + dead_process.poll.return_value = 1 + with ( + patch("provision_piv_emulation._run", return_value=_completed()), + patch("provision_piv_emulation.subprocess.Popen", return_value=dead_process), + patch("provision_piv_emulation.time.sleep"), + pytest.raises(provision_piv_emulation.ProvisioningError, match="exited immediately"), + ): + provision_piv_emulation._start_pcscd(tmp_path / "bin" / "pcscd") - def test_removes_existing_bundle_before_copying(self, tmp_path: Path) -> None: - existing = tmp_path / "ifd-vpcd.bundle" - existing.mkdir() + def test_returns_process_when_still_running(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + live_process = MagicMock() + live_process.poll.return_value = None with ( - patch("provision_piv_emulation._DRIVER_DEST", existing), patch("provision_piv_emulation._run", return_value=_completed()) as mock_run, + patch("provision_piv_emulation.subprocess.Popen", return_value=live_process) as mock_popen, + patch("provision_piv_emulation.time.sleep"), ): - provision_piv_emulation._install_vpcd_bundle(tmp_path / "store-path", vendor_id=1, product_id=2) + result = provision_piv_emulation._start_pcscd(tmp_path / "bin" / "pcscd") - first_call = mock_run.call_args_list[0].args[0] - assert first_call[:3] == ["sudo", "rm", "-rf"] + assert result is live_process + # Runs detached (survives past this script's own exit) -- the + # pamtester step that needs it runs afterward, in a separate SSH + # call. + assert mock_popen.call_args.kwargs["start_new_session"] is True + mkdir_calls = [c.args[0] for c in mock_run.call_args_list if c.args[0][:2] == ["sudo", "mkdir"]] + assert any(str(provision_piv_emulation._PCSCD_IPC_DIR) in call for call in mkdir_calls) class TestStartJcardsim: @@ -55,18 +90,26 @@ def test_raises_if_process_exits_immediately(self, tmp_path: Path, monkeypatch: patch("provision_piv_emulation.time.sleep"), pytest.raises(provision_piv_emulation.ProvisioningError, match="exited immediately"), ): - provision_piv_emulation._start_jcardsim(tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes") + provision_piv_emulation._start_jcardsim( + tmp_path / "jdk", tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes" + ) def test_returns_process_when_still_running(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) # _start_jcardsim writes jcardsim-mac2nix.cfg to the CWD live_process = MagicMock() live_process.poll.return_value = None with ( - patch("provision_piv_emulation.subprocess.Popen", return_value=live_process), + patch("provision_piv_emulation.subprocess.Popen", return_value=live_process) as mock_popen, patch("provision_piv_emulation.time.sleep"), ): - result = provision_piv_emulation._start_jcardsim(tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes") + result = provision_piv_emulation._start_jcardsim( + tmp_path / "jdk", tmp_path / "jcardsim.jar", tmp_path / "pivapplet-classes" + ) assert result is live_process + # Runs detached (survives past this script's own exit) -- the + # pamtester step that needs it runs afterward, in a separate SSH + # call. + assert mock_popen.call_args.kwargs["start_new_session"] is True class TestWaitForVpcdListener: @@ -97,56 +140,101 @@ def _flaky_connect(*_args: object, **_kwargs: object) -> MagicMock: assert call_count == 3 assert mock_sleep.call_count == 2 - def test_raises_after_exhausting_attempts(self) -> None: + def test_raises_after_exhausting_attempts(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) with ( patch( "provision_piv_emulation.socket.create_connection", side_effect=ConnectionRefusedError("Connection refused"), ), patch("provision_piv_emulation.time.sleep"), - patch("provision_piv_emulation._run", return_value=_completed(stdout="diagnostic output")) as mock_run, + patch("provision_piv_emulation._run", return_value=_completed(stdout="reader.conf contents")) as mock_run, pytest.raises(provision_piv_emulation.ProvisioningError, match="never started listening"), ): provision_piv_emulation._wait_for_vpcd_listener(max_attempts=3) - commands = [c.args[0][0] for c in mock_run.call_args_list] - assert commands == ["system_profiler", "log"] + commands = [c.args[0] for c in mock_run.call_args_list] + assert commands == [["sudo", "cat", str(provision_piv_emulation._READER_CONF_PATH)]] - def test_error_includes_diagnostic_output(self) -> None: + def test_error_includes_pcscd_log_when_present(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pcscd.log").write_text("RFLoadReader failed: 0x80100014") with ( patch( "provision_piv_emulation.socket.create_connection", side_effect=ConnectionRefusedError("Connection refused"), ), patch("provision_piv_emulation.time.sleep"), - patch("provision_piv_emulation._run", return_value=_completed(stdout="(null):(null) ifd-vpcd.bundle")), - pytest.raises(provision_piv_emulation.ProvisioningError, match=r"\(null\):\(null\)"), + patch("provision_piv_emulation._run", return_value=_completed()), + pytest.raises(provision_piv_emulation.ProvisioningError, match="RFLoadReader failed"), ): provision_piv_emulation._wait_for_vpcd_listener(max_attempts=3) +class TestSelectApplet: + def test_invokes_the_built_opensc_tool_with_full_aid(self, tmp_path: Path) -> None: + with patch("provision_piv_emulation._run", return_value=_completed()) as mock_run: + provision_piv_emulation._select_applet(tmp_path) + called = mock_run.call_args.args[0] + assert called[0] == str(tmp_path / "bin" / "opensc-tool") + assert provision_piv_emulation._SELECT_APPLET_APDU in called + + class TestWaitForCard: - def test_returns_immediately_once_card_visible(self) -> None: + def test_returns_immediately_once_card_visible(self, tmp_path: Path) -> None: with ( - patch("provision_piv_emulation._run", return_value=_completed(stdout="... PIV ...")) as mock_run, + patch( + "provision_piv_emulation._run", + return_value=_completed(stdout="0 Yes Virtual PCD 00 00"), + ) as mock_run, patch("provision_piv_emulation.time.sleep") as mock_sleep, ): - provision_piv_emulation._wait_for_card(max_attempts=5) + provision_piv_emulation._wait_for_card(tmp_path, max_attempts=5) assert mock_run.call_count == 1 mock_sleep.assert_not_called() - def test_raises_after_exhausting_attempts(self) -> None: + def test_raises_after_exhausting_attempts(self, tmp_path: Path) -> None: with ( - patch("provision_piv_emulation._run", return_value=_completed(stdout="nothing here")) as mock_run, + patch( + "provision_piv_emulation._run", + return_value=_completed(stdout="0 No Virtual PCD 00 00"), + ) as mock_run, patch("provision_piv_emulation.time.sleep"), pytest.raises(provision_piv_emulation.ProvisioningError, match="did not appear"), ): - provision_piv_emulation._wait_for_card(max_attempts=3, delay_seconds=0) + provision_piv_emulation._wait_for_card(tmp_path, max_attempts=3, delay_seconds=0) assert mock_run.call_count == 3 +class TestProvisionPivSlot: + def test_uses_virtual_reader_filter_not_yubikey_default(self, tmp_path: Path) -> None: + # yubico-piv-tool's own `-r` default is "Yubikey", which never + # matches vpcd's "Virtual PCD ..." reader name -- a real, + # previously-latent bug (see _READER_NAME_FILTER's own comment). + with patch("provision_piv_emulation._run", return_value=_completed()) as mock_run: + provision_piv_emulation._provision_piv_slot(tmp_path) + + for call in mock_run.call_args_list: + cmd = call.args[0] + assert cmd[0] == str(tmp_path / "bin" / "yubico-piv-tool") + assert "-r" in cmd + assert cmd[cmd.index("-r") + 1] == "Virtual" + + def test_runs_generate_then_selfsign_then_import(self, tmp_path: Path) -> None: + with patch("provision_piv_emulation._run", return_value=_completed()) as mock_run: + provision_piv_emulation._provision_piv_slot(tmp_path) + + actions = [call.args[0][call.args[0].index("-a") + 1] for call in mock_run.call_args_list] + assert actions == ["generate", "verify-pin", "import-certificate"] + + class TestProvisionOrchestration: - def test_runs_stages_in_order_and_cleans_up_jcardsim(self, tmp_path: Path) -> None: - process = MagicMock() + def test_runs_stages_in_order_without_terminating_anything(self, tmp_path: Path) -> None: + # pcscd and jcardsim must both survive past provision()'s own + # return -- the pamtester authentication step that needs them runs + # afterward, in a separate SSH call. This is real behavior this + # test protects: the original script used to call + # jcardsim_process.terminate() in a finally block, which would have + # broken that later step the moment it was ever reached. calls: list[str] = [] def _record(name: str) -> MagicMock: @@ -159,22 +247,21 @@ def _fn(*_args: object, **_kwargs: object) -> object: with ( patch("provision_piv_emulation.shutil.which", return_value="/usr/bin/nix-build"), patch("provision_piv_emulation._nix_build", side_effect=lambda attr: tmp_path / attr), - patch("provision_piv_emulation._install_vpcd_bundle", _record("install_vpcd")), + patch("provision_piv_emulation._write_reader_conf", _record("write_reader_conf")), + patch("provision_piv_emulation._start_pcscd", _record("start_pcscd")), patch("provision_piv_emulation._wait_for_vpcd_listener", _record("wait_for_vpcd_listener")), - patch( - "provision_piv_emulation._start_jcardsim", - MagicMock(side_effect=lambda *_a: (calls.append("start_jcardsim"), process)[1]), - ), + patch("provision_piv_emulation._start_jcardsim", _record("start_jcardsim")), patch("provision_piv_emulation._select_applet", _record("select_applet")), patch("provision_piv_emulation._wait_for_card", _record("wait_for_card")), patch("provision_piv_emulation._provision_piv_slot", _record("provision_piv_slot")), patch("provision_piv_emulation._export_certificate", _record("export_certificate")), patch("pathlib.Path.glob", return_value=[tmp_path / "jcardsim.jar"]), ): - provision_piv_emulation.provision(vendor_id=1452, product_id=33029) + provision_piv_emulation.provision() assert calls == [ - "install_vpcd", + "write_reader_conf", + "start_pcscd", "wait_for_vpcd_listener", "start_jcardsim", "select_applet", @@ -182,20 +269,19 @@ def _fn(*_args: object, **_kwargs: object) -> object: "provision_piv_slot", "export_certificate", ] - process.terminate.assert_called_once() def test_raises_if_nix_build_not_on_path(self) -> None: with ( patch("provision_piv_emulation.shutil.which", return_value=None), pytest.raises(provision_piv_emulation.ProvisioningError, match="nix-build is not on PATH"), ): - provision_piv_emulation.provision(vendor_id=1, product_id=2) + provision_piv_emulation.provision() class TestMain: def test_returns_1_and_logs_on_provisioning_error(self) -> None: with ( - patch("sys.argv", ["provision_piv_emulation.py", "--vendor-id", "1452", "--product-id", "33029"]), + patch("sys.argv", ["provision_piv_emulation.py"]), patch( "provision_piv_emulation.provision", side_effect=provision_piv_emulation.ProvisioningError("boom"), @@ -207,7 +293,7 @@ def test_returns_1_and_logs_on_provisioning_error(self) -> None: def test_returns_0_on_success(self) -> None: with ( - patch("sys.argv", ["provision_piv_emulation.py", "--vendor-id", "1452", "--product-id", "33029"]), + patch("sys.argv", ["provision_piv_emulation.py"]), patch("provision_piv_emulation.provision"), ): assert provision_piv_emulation.main() == 0 From bae56facd12f11e30300d4113c2e3f8186282080 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 12 Aug 2026 20:03:24 -0400 Subject: [PATCH 12/12] fix(vm): authenticates PIV E2E tests via real sudo 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. --- tests/vm/test_piv_sudo_native.py | 73 ++++++++++----- tests/vm/test_piv_sudo_vm.py | 149 ++++++++++++++++++++++++++----- 2 files changed, 182 insertions(+), 40 deletions(-) diff --git a/tests/vm/test_piv_sudo_native.py b/tests/vm/test_piv_sudo_native.py index 30f37c4..5cde911 100644 --- a/tests/vm/test_piv_sudo_native.py +++ b/tests/vm/test_piv_sudo_native.py @@ -2,14 +2,7 @@ Marked `nix_darwin_switch`, same skip-unless-`GITHUB_ACTIONS=true` guard as `test_scaffold_switch_native.py` — never runs against a real developer -machine. Unlike that test, this one requires two more environment -variables (`MAC2NIX_PIV_VENDOR_ID`/`MAC2NIX_PIV_PRODUCT_ID`) that only exist -when `pr-checks.yaml`'s discovery step (`scripts/discover_usb_device.py`) -found a usable baseline USB device on this specific runner — if it didn't, -the CI workflow's own conditional skips the step that would run this test -entirely, which is a deliberate, documented fallback (see -hack/plans/fix-vm-tahoe-base-image-1785337468-migration-mvp.md's Task 10 -Step 5), not something this test itself needs to handle. +machine. Must run *after* `test_scaffold_switch_native.py`'s own switch step in the CI workflow, never before or concurrently — see @@ -18,6 +11,33 @@ splices a live PAM module into this runner's real `/etc/pam.d/sudo_local` for the remainder of the job, and nothing else in that job may still depend on plain-password `sudo` succeeding once it does. + +`scripts/provision_piv_emulation.py` no longer needs a discovered USB +device at all (see its own docstring): it registers vpcd via its own +reader.conf mechanism against a self-hosted pcscd +(nix/piv-emulation/pcsc-stack.nix), not macOS's proprietary +CryptoTokenKit/ifdreader daemon, which is what actually needed a +VID/PID-matched USB device to spoof. + +This matters concretely for this leg, not just architecturally: a separate +session (hack/PROJECT.md's "native-runner leg validated against real GHA +hardware" entry, 2026-08-12) confirmed via three real CI runs that +`runs-on: macos-latest` exposes exactly two USB devices, byte-for-byte +identical to Tart's own baseline, and both are HID-claimed the same way -- +`scripts/discover_usb_device.py` now correctly excludes both as unusable, +which means this test's own CI step (gated on a device being discovered) +currently never actually executes on a real runner; it always lands on the +documented "no usable device, skip" fallback instead. The self-hosted-pcscd +mechanism needs no discovered device at all, so it removes the reason that +fallback exists in the first place -- but that has not yet been verified +against a real `macos-latest` runner (this session's own verification was +against a real Tart VM only). `MAC2NIX_PIV_VENDOR_ID`/ +`MAC2NIX_PIV_PRODUCT_ID`, `scripts/discover_usb_device.py`, and +`pr-checks.yaml`'s conditional gating on them are consequently no longer +load-bearing for this leg, but are left in place un-deleted pending a real +CI run confirming the new mechanism actually works here too -- a CI +workflow change is a higher-stakes, harder-to-verify-locally edit than the +Python it gates, and is deliberately out of scope for this session. """ from __future__ import annotations @@ -90,13 +110,10 @@ def discovered_usb_device() -> tuple[int, int]: return int(vendor_id), int(product_id) -def test_piv_card_authenticates_against_sudo_pam_natively( - real_age_key: Path, discovered_usb_device: tuple[int, int], tmp_path: Path -) -> None: +def test_piv_card_authenticates_against_sudo_pam_natively(real_age_key: Path, tmp_path: Path) -> None: """A real virtual PIV card, provisioned directly on this runner, authenticates via pam_p11.""" username = getpass.getuser() output_dir = tmp_path / "mac2nix-scaffold" - vendor_id, product_id = discovered_usb_device init_framework(output_dir) add_host(output_dir, _HOSTNAME, username, confirm_backup=lambda _fingerprint: True) @@ -145,10 +162,6 @@ async def _switch() -> tuple[int, str, str]: "nixpkgs#python3", "--", str(provision_script), - "--vendor-id", - str(vendor_id), - "--product-id", - str(product_id), ], capture_output=True, text=True, @@ -159,13 +172,33 @@ async def _switch() -> tuple[int, str, str]: f"PIV emulation provisioning failed:\nstdout:\n{provision_result.stdout}\nstderr:\n{provision_result.stderr}" ) - pamtester_result = subprocess.run( # noqa: S603 - ["bash", "-c", f"echo 123456 | {nix_bin} run nixpkgs#pamtester -- sudo_local {username} authenticate"], # noqa: S607 + # Real "sudo" PAM authentication, never a nix-built PAM-testing tool like + # pamtester -- see tests/vm/test_piv_sudo_vm.py's own _sudo_authenticate + # docstring for the full, VM-confirmed reasoning: nix-built binaries and + # nixpkgs' own libpam.2.dylib are rejected outright by AppleMobileFileIntegrity + # ("Unrecoverable CT signature issue"), and OpenPAM's own + # openpam_check_path_owner_perms() independently refuses to load any module + # from /nix/store at all ("insecure ownership or permissions") -- both + # confirmed via a direct `log show` capture, regardless of which PAM service + # is targeted or how its policy is written. Only a real, Apple-signed + # /usr/bin/sudo can drive this PAM chain successfully. + # + # This runner's own sudo has no NOPASSWD override (unlike Tart's base + # image, see _sudo_authenticate's docstring) -- GitHub-hosted macOS + # runners require a real password for sudo by default -- so no + # NOPASSWD-removal step is needed here. + # + # -k invalidates any cached sudo timestamp first, so this always exercises + # a real PAM authentication rather than a cached credential. -S reads the + # password from stdin. -v only validates/refreshes credentials -- no + # command execution needed to prove authentication succeeded or failed. + sudo_auth_result = subprocess.run( # noqa: S603 + ["bash", "-c", f"echo 123456 | {sudo_bin} -k -S -v"], # noqa: S607 capture_output=True, text=True, timeout=60, check=False, ) - assert pamtester_result.returncode == 0, ( - f"pamtester authentication failed:\nstdout:\n{pamtester_result.stdout}\nstderr:\n{pamtester_result.stderr}" + assert sudo_auth_result.returncode == 0, ( + f"sudo authentication failed:\nstdout:\n{sudo_auth_result.stdout}\nstderr:\n{sudo_auth_result.stderr}" ) diff --git a/tests/vm/test_piv_sudo_vm.py b/tests/vm/test_piv_sudo_vm.py index 2c47f86..c0ada3c 100644 --- a/tests/vm/test_piv_sudo_vm.py +++ b/tests/vm/test_piv_sudo_vm.py @@ -13,9 +13,15 @@ physical YubiKey, per this plan's own research spike (hack/research/feat-migration-mvp-pr1-1786215169-piv-smartcard-emulation-tart-macos.md). -The spoof target (Virtual USB Keyboard, idVendor 1452 / idProduct 33029) was -confirmed live this session via `ioreg -p IOUSB -l` against a real Tart -guest -- it is not documented anywhere upstream, it was discovered here. +Registers vpcd via its own reader.conf mechanism against a self-hosted +pcscd (nix/piv-emulation/pcsc-stack.nix) -- never macOS's proprietary +CryptoTokenKit/ifdreader daemon, which requires spoofing a USB device's +VID/PID and is a confirmed dead end on Tart specifically (its only two +synthetic USB devices are already claimed by macOS's own HIDDriverKit +stack, so ifdreader registers vpcd's driver bundle but never gets a live +reader instance for it -- see hack/PROJECT.md's "real VM debugging session" +entry for the full diagnostic trail). No USB device, real or synthetic, is +involved in this test at all as a result. """ from __future__ import annotations @@ -39,26 +45,61 @@ # own reasoning for reusing this account rather than a synthetic one. _VM_USERNAME = "admin" -# Confirmed live this session via a real ioreg spike against macos-tahoe-base -# -- a baseline synthetic USB device Virtualization.framework always -# provides for guest keyboard input, present with zero configuration. -_VENDOR_ID = 1452 -_PRODUCT_ID = 33029 - _REMOTE_PIV_ROOT = "/tmp/mac2nix-piv" _NIX_PROFILE_SOURCE_CMD = ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" +# Swaps `pkgs.opensc` (which security.nix's PAM wiring references directly, +# `${pkgs.opensc}/lib/opensc-pkcs11.so`) for a build linked against +# nixpkgs' own pcsclite instead of Apple's proprietary PCSC.framework -- +# necessary because this test authenticates against an *emulated* card, +# only reachable through the self-hosted pcscd +# scripts/provision_piv_emulation.py starts, never through Apple's +# CryptoTokenKit/ifdreader daemon. Injected only into this generated *test* +# scaffold's own configuration.nix -- the real +# templates/scaffold/modules/darwin/security.nix that real users get is +# never touched, and always resolves `pkgs.opensc` to the stock, +# Apple-PCSC-linked build, which is exactly correct for a real Mac talking +# to a real YubiKey. +# +# Deliberately duplicated (not shared via import) with nix/piv-emulation/ +# pcsc-stack.nix's identical-looking override: that file overrides a +# separately-pinned nixpkgs used only to build the provisioning tools +# themselves, while this overlay is evaluated within *this scaffold's own* +# flake (a different nixpkgs revision, resolved via its own flake.lock) -- +# a flake can't import a file outside its own source tree under pure +# evaluation, so the same override logic has to exist in both places. +_OPENSC_PCSCLITE_OVERLAY = """ + nixpkgs.overlays = [ + (final: prev: { + pcsclite = prev.pcsclite.overrideAttrs (old: { + postPatch = builtins.replaceStrings + [ ''"$lib/lib/libpcsclite_real.so.1"'' ] + [ ''"$lib/lib/libpcsclite_real.1.dylib"'' ] + old.postPatch; + }); + opensc = prev.opensc.overrideAttrs (old: { + buildInputs = old.buildInputs ++ [ final.pcsclite ]; + configureFlags = old.configureFlags ++ [ + ("--with-pcsc-provider=${prev.lib.getLib final.pcsclite}/lib/libpcsclite" + + prev.stdenv.hostPlatform.extensions.sharedLibrary) + ]; + }); + }) + ]; +""" + def _enable_yubikey_piv_sudo(output_dir: Path, hostname: str) -> None: """Patch a registered host's configuration.nix to set `mac2nix.yubikeyPivSudo.enable = true;`. Mirrors how a real user enables the option -- hand-editing their host's own configuration.nix -- rather than inventing a test-only override. + Also injects `_OPENSC_PCSCLITE_OVERLAY` (see its own comment above). """ config_path = output_dir / "hosts" / "darwin" / hostname / "configuration.nix" content = config_path.read_text() marker = "system.stateVersion = 7;" - replacement = f"{marker}\n mac2nix.yubikeyPivSudo.enable = true;" + replacement = f"{marker}\n mac2nix.yubikeyPivSudo.enable = true;\n{_OPENSC_PCSCLITE_OVERLAY}" config_path.write_text(content.replace(marker, replacement)) @@ -162,17 +203,67 @@ async def _run_provisioning(vm: TartVMManager) -> None: f"{_NIX_PROFILE_SOURCE_CMD}" f" && cd {_REMOTE_PIV_ROOT}" f" && sudo -n $(command -v nix) run nixpkgs#python3 -- scripts/provision_piv_emulation.py" - f" --vendor-id {_VENDOR_ID} --product-id {_PRODUCT_ID}" ) ok, out, err = await vm.exec_command(["bash", "-c", provision_cmd], timeout=1800) if not ok: raise VMError(f"PIV emulation provisioning failed:\nstdout:\n{out}\nstderr:\n{err}") -async def _pamtester_authenticate(vm: TartVMManager, *, pin: str) -> tuple[bool, str, str]: - cmd = ( - f"{_NIX_PROFILE_SOURCE_CMD} && echo {pin} | nix run nixpkgs#pamtester -- sudo_local {_VM_USERNAME} authenticate" - ) +async def _remove_nopasswd_sudoers(vm: TartVMManager) -> None: + """Remove Tart's `admin-nopasswd` sudoers override so `sudo` actually authenticates. + + Real, necessary prerequisite discovered this session, not a cosmetic + change: Tart's base image ships `/etc/sudoers.d/admin-nopasswd` + (`(ALL) NOPASSWD: ALL`, confirmed via a direct VM `sudo -l`) purely for + CI/automation convenience. With it present, *any* `sudo` invocation for + `admin` skips PAM authentication entirely (`pam_authenticate()` is never + called) -- meaning a test built on real `sudo` would "pass" or "fail" + for a reason having nothing to do with pam_p11 or the PIV card at all. + Real Macs never ship this file; it's a Tart-image-only default this + test must undo to get real coverage. `sudo -n` still works to remove it + (NOPASSWD is still in effect for *this* command). + """ + ok, _out, err = await vm.exec_command(["sudo", "-n", "rm", "-f", "/etc/sudoers.d/admin-nopasswd"]) + if not ok: + raise VMError(f"Failed to remove admin-nopasswd sudoers override: {err.strip()}") + + +async def _sudo_authenticate(vm: TartVMManager, *, pin: str) -> tuple[bool, str, str]: + """Authenticate via a real `sudo` invocation -- never a PAM-testing tool like pamtester. + + This replaces an earlier `pamtester`-based design that could never have + worked on this macOS version, for a reason unrelated to PIV/pcscd + entirely: real diagnostic evidence (a direct VM `log show` capture) + shows the kernel's AppleMobileFileIntegrity subsystem rejecting + nix-built pamtester and nixpkgs' own `libpam.2.dylib` outright + ("Unrecoverable CT signature issue, bailing out"), and OpenPAM's own + `openpam_check_path_owner_perms()` separately refusing to load *any* + module from `/nix/store` at all ("insecure ownership or permissions") + -- both confirmed via the unified log, not inferred. This holds + regardless of which PAM service is targeted or how its policy is + written (a fully self-contained, nix-store-only "test" service using + nixpkgs' own `pam_permit.so` for every management group was tried and + hit the identical failure) -- it is a structural property of using any + nix-built, non-Apple-signed process to drive PAM on this macOS version, + not a configuration bug. + + Real `/usr/bin/sudo`, linked against Apple's own signed + `libpam.2.dylib`, has neither problem -- the same mechanism nix-darwin's + own `security.pam.services.sudo_local.reattach` option already relies + on in production to load `pam_reattach.so` from the nix store. + Confirmed directly on a real VM: with `admin-nopasswd` removed (see + `_remove_nopasswd_sudoers`), `echo admin | sudo -k -S -v` exits 0 for + the real account password and 1 for a wrong one -- clean, real PAM + gating, zero AMFI/OpenPAM path-security failures. + + `-k` invalidates any cached sudo timestamp first (otherwise a prior + successful auth in the same session could skip PAM entirely, the same + class of false-pass `admin-nopasswd` caused). `-S` reads the PIN from + stdin instead of a TTY. `-v` only validates/refreshes credentials -- + no command execution needed to prove authentication succeeded or + failed. + """ + cmd = f"echo {pin} | sudo -k -S -v" return await vm.exec_command(["bash", "-c", cmd], timeout=60) @@ -199,6 +290,7 @@ async def _run() -> tuple[bool, str, str]: await _switch_scaffold(nix_darwin_vm, validator, output_dir, local_key_path) await _copy_provisioning_assets(nix_darwin_vm, validator) await _run_provisioning(nix_darwin_vm) + await _remove_nopasswd_sudoers(nix_darwin_vm) # Attribution matters: touchIdAuth sits before pam_p11 in the same # `sufficient` chain (lib.mkAfter) and is unconditional. A bare @@ -206,10 +298,10 @@ async def _run() -> tuple[bool, str, str]: # reason on hardware with no biometric sensor -- masking a broken # PIV path entirely. The negative case below is what actually proves # attribution, not this call alone. - return await _pamtester_authenticate(nix_darwin_vm, pin="123456") + return await _sudo_authenticate(nix_darwin_vm, pin="123456") ok, out, err = asyncio.run(_run()) - assert ok, f"pamtester authentication failed:\nstdout:\n{out}\nstderr:\n{err}" + assert ok, f"sudo authentication failed:\nstdout:\n{out}\nstderr:\n{err}" def test_piv_card_wrong_pin_fails_authentication( @@ -239,8 +331,25 @@ async def _run() -> tuple[bool, str, str]: await _switch_scaffold(nix_darwin_vm, validator, output_dir, local_key_path) await _copy_provisioning_assets(nix_darwin_vm, validator) await _run_provisioning(nix_darwin_vm) + await _remove_nopasswd_sudoers(nix_darwin_vm) - return await _pamtester_authenticate(nix_darwin_vm, pin="000000") + return await _sudo_authenticate(nix_darwin_vm, pin="000000") - ok, out, _err = asyncio.run(_run()) - assert not ok, f"pamtester authenticated with a wrong PIN — PIV path is not actually gating auth:\n{out}" + ok, out, err = asyncio.run(_run()) + # A wrong PIN must not simply produce pam_p11's own rejection message -- + # it must produce the SAME kind of failure pattern real `sudo` gives for + # any wrong credential (a clean, real "Sorry, try again."/exit-1 PAM + # rejection), not an "Initialization failure"/"System error" that would + # also occur if the PAM stack itself were broken (see `_sudo_authenticate`'s + # own docstring for why that distinction was invisible to the earlier + # pamtester-based design). Asserting only `not ok` here would be a lazily + # evaluated, always-true-looking check for either case; the `out`/`err` + # content is inspected precisely so a broken stack can't masquerade as a + # correctly rejected wrong PIN. + assert not ok, f"sudo authenticated with a wrong PIN — PIV path is not actually gating auth:\n{out}" + broken_stack_msg = ( + f"sudo rejected the wrong PIN, but with a PAM-stack-broken error, not a real credential " + f"rejection:\nstdout:\n{out}\nstderr:\n{err}" + ) + assert "system error" not in err.lower(), broken_stack_msg + assert "initialization failure" not in err.lower(), broken_stack_msg