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/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. diff --git a/nix/piv-emulation/default.nix b/nix/piv-emulation/default.nix new file mode 100644 index 0000000..242ac23 --- /dev/null +++ b/nix/piv-emulation/default.nix @@ -0,0 +1,43 @@ +# 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. +# +# 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 { } }: +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/jcardsim.nix b/nix/piv-emulation/jcardsim.nix new file mode 100644 index 0000000..76db9ba --- /dev/null +++ b/nix/piv-emulation/jcardsim.nix @@ -0,0 +1,131 @@ +# 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. +{ + fetchFromGitHub, + fetchurl, + maven, + jdk8, + runCommand, +}: +let + apiClassicJar = fetchurl { + url = "https://raw.githubusercontent.com/martinpaljak/oracle_javacard_sdks/6a75ec0d6913db236d354f154df7dbc9573d976d/jc305u4_kit/lib/api_classic.jar"; + hash = "sha256-xDCNvuGS3D8SUJEhZg0HG0b6vUQjxL+XFH4DnB0WLtg="; + }; + + # 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 { + 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; + env.JC_CLASSIC_HOME = "${jcClassicHome}"; + + # 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). + # + # `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 + ''; + }; + # 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 + # 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)"; + license = "Apache-2.0"; + }; +} 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/pivapplet.nix b/nix/piv-emulation/pivapplet.nix new file mode 100644 index 0000000..fdbf938 --- /dev/null +++ b/nix/piv-emulation/pivapplet.nix @@ -0,0 +1,93 @@ +# 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. +{ + fetchFromGitHub, + stdenv, + jdk8, + ant, +}: +let + jc222Kit = fetchFromGitHub { + owner = "martinpaljak"; + repo = "oracle_javacard_sdks"; + rev = "6a75ec0d6913db236d354f154df7dbc9573d976d"; + hash = "sha256-RJTus6PjN5f+WfN+N44HIkSgFc8QHHIMY+Fps0M7XF4="; # verified via a real build this session + 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 = "sha256-Ecf/lv54dC9lUzuKTw/WU/vq1ptzgY3vHH26ZvQlLPo="; # verified via a real build this session + }; + + 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. + # + # 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 -DPIV_SUPPORT_EC=false 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..dd15973 --- /dev/null +++ b/nix/piv-emulation/vpcd.nix @@ -0,0 +1,100 @@ +# 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 +# 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. +# +# 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 the same IFD-handler API the +# self-hosted pcsc-stack.nix pcscd actually loads it with at runtime. +{ + 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/discover_usb_device.py b/scripts/discover_usb_device.py new file mode 100644 index 0000000..2864566 --- /dev/null +++ b/scripts/discover_usb_device.py @@ -0,0 +1,128 @@ +"""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 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. + +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) + +# 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+)') + + +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: + print(f"ioreg failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr) # noqa: T201 + return None + + # 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: + 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 _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: + 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/scripts/provision_piv_emulation.py b/scripts/provision_piv_emulation.py new file mode 100644 index 0000000..d55f2d0 --- /dev/null +++ b/scripts/provision_piv_emulation.py @@ -0,0 +1,329 @@ +"""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/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). +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`` +""" + +from __future__ import annotations + +import argparse +import logging +import re +import shutil +import socket +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" + +# 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 + + +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 _write_reader_conf(vpcd_bundle: Path) -> None: + """Register vpcd as an always-available, non-hotplug PC/SC reader. + + 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' + ) + 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. + + 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): + 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) + + 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"{_READER_CONF_PATH}:\n{reader_conf.stdout}\n" + f"pcscd log:\n{pcscd_log_contents}" + ) + + +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" + 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}" + 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: + raise ProvisioningError(f"jcardsim's VSmartCard process exited immediately:\n{log_path.read_text()}") + return process + + +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(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([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 after {max_attempts} attempts") + + +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. + 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, + "-r", + _READER_NAME_FILTER, + "-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, "-r", _READER_NAME_FILTER, "-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() -> 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 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") + + _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( + jcardsim_path.glob("**/jcardsim*.jar") + ) + if not jcardsim_jar_candidates: + raise ProvisioningError(f"No jcardsim jar found under {jcardsim_path}") + + # 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.parse_args() + + try: + provision() + 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/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/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/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}" diff --git a/tests/test_discover_usb_device.py b/tests/test_discover_usb_device.py new file mode 100644 index 0000000..094dab2 --- /dev/null +++ b/tests/test_discover_usb_device.py @@ -0,0 +1,133 @@ +"""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 + } +""" + +# 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: + 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_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 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( + "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, 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: + 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/test_provision_piv_emulation.py b/tests/test_provision_piv_emulation.py new file mode 100644 index 0000000..3683fdf --- /dev/null +++ b/tests/test_provision_piv_emulation.py @@ -0,0 +1,299 @@ +"""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 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] + 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_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._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"), + ): + result = provision_piv_emulation._start_pcscd(tmp_path / "bin" / "pcscd") + + 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: + 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 / "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) as mock_popen, + patch("provision_piv_emulation.time.sleep"), + ): + 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: + 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, 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="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] for c in mock_run.call_args_list] + assert commands == [["sudo", "cat", str(provision_piv_emulation._READER_CONF_PATH)]] + + 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()), + 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, tmp_path: Path) -> None: + with ( + 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(tmp_path, max_attempts=5) + assert mock_run.call_count == 1 + mock_sleep.assert_not_called() + + def test_raises_after_exhausting_attempts(self, tmp_path: Path) -> None: + with ( + 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(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_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: + 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._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", _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() + + assert calls == [ + "write_reader_conf", + "start_pcscd", + "wait_for_vpcd_listener", + "start_jcardsim", + "select_applet", + "wait_for_card", + "provision_piv_slot", + "export_certificate", + ] + + 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() + + +class TestMain: + def test_returns_1_and_logs_on_provisioning_error(self) -> None: + with ( + patch("sys.argv", ["provision_piv_emulation.py"]), + 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"]), + patch("provision_piv_emulation.provision"), + ): + assert provision_piv_emulation.main() == 0 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_native.py b/tests/vm/test_piv_sudo_native.py new file mode 100644 index 0000000..5cde911 --- /dev/null +++ b/tests/vm/test_piv_sudo_native.py @@ -0,0 +1,204 @@ +"""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. + +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. + +`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 + +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, 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" + + 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), + ], + 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}" + ) + + # 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 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 new file mode 100644 index 0000000..c0ada3c --- /dev/null +++ b/tests/vm/test_piv_sudo_vm.py @@ -0,0 +1,355 @@ +"""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). + +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 + +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, is_transient_auth_failure +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" + +_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;\n{_OPENSC_PCSCLITE_OVERLAY}" + 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", + # 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()}") + + 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" + ) + 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 _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) + + +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) + 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 + # "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 _sudo_authenticate(nix_darwin_vm, pin="123456") + + ok, out, err = asyncio.run(_run()) + assert ok, f"sudo 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) + await _remove_nopasswd_sudoers(nix_darwin_vm) + + return await _sudo_authenticate(nix_darwin_vm, pin="000000") + + 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 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 # ---------------------------------------------------------------------------