Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ uv sync
uv run mac2nix --help
```

## Runbooks

- [YubiKey PIV login + sudo](docs/runbooks/yubikey-piv.md)

## License

MIT
170 changes: 170 additions & 0 deletions docs/runbooks/yubikey-piv.md
Original file line number Diff line number Diff line change
@@ -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 <username> -h <hash-from-sc_auth-identities>
```

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 <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 <path-to-pam_p11.so> <path-to-opensc-pkcs11.so>
```

**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 .#<hostname>
```

(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.
43 changes: 43 additions & 0 deletions nix/piv-emulation/default.nix
Original file line number Diff line number Diff line change
@@ -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 `<nixpkgs>`) because this project has no
# top-level flake.lock to pin it otherwise, and `<nixpkgs>` 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*
# `<nixpkgs>` 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;
}
Loading
Loading