From dea7ca451aebca26ef51f1f7dc2da505c55492fa Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Mon, 22 Jun 2026 20:50:48 +0000 Subject: [PATCH] Adding dot1x support --- CLAUDE.md | 7 + docs/TODO.md | 11 ++ serving/dot1x-bench/.env.example | 5 + serving/dot1x-bench/.gitignore | 10 ++ serving/dot1x-bench/README.md | 126 +++++++++++++++++ .../authenticator/hostapd-wired.conf | 33 +++++ .../dot1x-bench/authenticator/setup-link.sh | 38 ++++++ serving/dot1x-bench/docker-compose.yml | 47 +++++++ serving/dot1x-bench/enrol/tpm-provision.sh | 79 +++++++++++ serving/dot1x-bench/enrol/tpm-selftest.sh | 129 ++++++++++++++++++ serving/dot1x-bench/freeradius/clients.conf | 9 ++ .../dot1x-bench/freeradius/mods-available/eap | 37 +++++ serving/dot1x-bench/step-ca/bootstrap.sh | 61 +++++++++ 13 files changed, 592 insertions(+) create mode 100644 serving/dot1x-bench/.env.example create mode 100644 serving/dot1x-bench/.gitignore create mode 100644 serving/dot1x-bench/README.md create mode 100644 serving/dot1x-bench/authenticator/hostapd-wired.conf create mode 100755 serving/dot1x-bench/authenticator/setup-link.sh create mode 100644 serving/dot1x-bench/docker-compose.yml create mode 100755 serving/dot1x-bench/enrol/tpm-provision.sh create mode 100755 serving/dot1x-bench/enrol/tpm-selftest.sh create mode 100644 serving/dot1x-bench/freeradius/clients.conf create mode 100644 serving/dot1x-bench/freeradius/mods-available/eap create mode 100755 serving/dot1x-bench/step-ca/bootstrap.sh diff --git a/CLAUDE.md b/CLAUDE.md index 42387ab..ed648b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,8 @@ serving/ Lab serving stack: dnsmasq + nginx + S CI-built + pulled; see .gitea/workflows/) serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA (kiosk endpoint; nginx reverse-proxies /kiosk/) +serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS + + hostapd authenticator + TPM enrolment .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI docs/ Design PDF and supporting notes ``` @@ -308,6 +310,11 @@ authenticator + FreeRADIUS on a veth/bridge (bridge needs `group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port `Authorized`, macsec0 addressed, NVMe namespace connected, verity root mapped, overlay writable, supplicant alive after switch-root. +This harness is scaffolded in **`serving/dot1x-bench/`** (step-ca as the fleet +CA on the serving host + FreeRADIUS EAP-TLS + a `hostapd` wired authenticator + +`tpm-provision.sh`), with an **isolated bridge per workstation** (the flat VLAN +can't authenticate — EAPOL is dropped and MACsec is point-to-point). See its +README; several configs there are still BENCH-VERIFY. Any change to the dracut module, supplicant config, or cmdline must pass this before merge. Template-only changes need `packer validate` + diff --git a/docs/TODO.md b/docs/TODO.md index 4d3e043..2e09803 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -620,6 +620,17 @@ smoke test. - **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`). Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0). + - **Bench harness scaffolded 2026-06-22: [serving/dot1x-bench/](../serving/dot1x-bench/).** + step-ca (fleet CA, on the serving host) signs each host's **TPM-generated CSR** + + issues the RADIUS server cert; FreeRADIUS does EAP-TLS (trusts step-ca root, + returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an + **isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped + + MACsec is P2P). `enrol/tpm-provision.sh` creates the key in the TPM at + `0x81010001` + writes the cert to NV `0x01800001`. Starting solution **signs a + CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the + documented upgrade on the same CA. **BENCH-VERIFY** items flagged in the files: + hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version. + PKI product decision (greenfield): start step-ca; OpenBao/EJBCA/Dogtag later. - **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot by the serving dnsmasq — drop the hardcoded `192.168.50.10`. - **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM diff --git a/serving/dot1x-bench/.env.example b/serving/dot1x-bench/.env.example new file mode 100644 index 0000000..737867c --- /dev/null +++ b/serving/dot1x-bench/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and edit, then: docker compose up -d step-ca +CA_DNS=ca.fleet.lab # DNS name(s) clients reach step-ca on (resolved by serving dnsmasq) +CA_PORT=8443 # step-ca listen port (host networking) +CA_PASSWORD=changeme-bench # password protecting the CA keys (bench only) +RADIUS_SECRET=testing123 # shared secret between the authenticator (hostapd) and FreeRADIUS diff --git a/serving/dot1x-bench/.gitignore b/serving/dot1x-bench/.gitignore new file mode 100644 index 0000000..69aa3ec --- /dev/null +++ b/serving/dot1x-bench/.gitignore @@ -0,0 +1,10 @@ +# CA state + all issued key material / certs — NEVER commit. Regenerate via +# step-ca/bootstrap.sh. The bench CA is throwaway; production PKI is separate. +pki/ +.env +# per-host enrolment scratch +enrol/out/ +*.key +*.csr +*.der +*.pem diff --git a/serving/dot1x-bench/README.md b/serving/dot1x-bench/README.md new file mode 100644 index 0000000..4346449 --- /dev/null +++ b/serving/dot1x-bench/README.md @@ -0,0 +1,126 @@ +# 802.1X EAP-TLS + MACsec secure-path bench + +Bench harness for the **secure path** (`disable_dot1x=false`): wired 802.1X +EAP-TLS with a **TPM-held client key**, link encryption via **MACsec/MKA**, then +DHCP + NVMe/TCP root over the encrypted link. This proves the half of the system +the open-port (`rd.dot1x=0`) lab build deliberately skips. Tracks TODO #8 / SSP +residual risk R-01. + +> **Status: scaffold.** Configs marked **BENCH-VERIFY** below are best-effort and +> need confirming on your wpa_supplicant / hostapd / tpm2 builds (see CLAUDE.md +> "Known bench-verify items"). This is a lab rig, expected to be iterated. + +## The three components + +| Component | Runs on | What it does | +|---|---|---| +| **step-ca** (`step-ca/`) | **serving host** | the fleet CA — signs each host's **TPM-generated CSR**, issues the RADIUS server cert, and is the trust anchor baked into the image as `radius-ca.pem`. | +| **FreeRADIUS** (`freeradius/`) | **serving host** | terminates EAP-TLS, validates the client cert against step-ca's root, returns the MSK so MKA can key MACsec. | +| **authenticator** (`authenticator/`) | **a node *between* each workstation and the serving network** | `hostapd` wired 802.1X + MACsec; bridges the decrypted link to the serving VLAN. One per-workstation P2P link. | + +The CA + RADIUS sit on the **trusted side** (the serving host). The authenticator +is the only thing on the **untrusted** link with each workstation. Workstations +never reach DHCP/NVMe except *through* an authenticated, MACsec-encrypted link. + +## Why a separate isolated-bridge topology (not your flat VLAN) + +802.1X EAPOL (`01:80:c2:00:00:03`) is a reserved multicast that switches/bridges +**drop by default**, and **MACsec is point-to-point per link** — so a single +shared bridge can't authenticate or encrypt per host, and if the serving host +shares that bridge there's no authenticator in the path at all. Keep the flat +VLAN for the open-port build; build this sandwich alongside it. + +``` + VM-A (supplicant) ──[vmbr-secA]── eth1 ┐ + │ authenticator node + VM-B (supplicant) ──[vmbr-secB]── eth2 ┤ (hostapd-wired + MACsec; + │ bridges macsecN ↔ eth0) + 820 ───[inline box / MACsec switch]── ┘ + eth0 │ + [vmbr-serve]──────┴── serving host (step-ca, FreeRADIUS, + dnsmasq, NVMe/TCP) +``` + +Per supplicant-facing bridge, EAPOL must be allowed through: +```bash +echo 8 > /sys/class/net//bridge/group_fwd_mask # forward 01:80:c2:00:00:03 +``` + +### Proxmox commands (VM legs) + +```bash +# One isolated bridge per workstation link (vmbr-secA, vmbr-secB) — see your +# /etc/network/interfaces or the Proxmox UI. Then, after each is up: +for b in vmbr-secA vmbr-secB; do echo 8 > /sys/class/net/$b/bridge/group_fwd_mask; done + +# Attach the workstation VM's SECURE nic to its isolated bridge: +qm set --net1 virtio,bridge=vmbr-secA +qm set --net1 virtio,bridge=vmbr-secB +# Authenticator VM: one nic per workstation link + one on the serving bridge: +qm set --net1 virtio,bridge=vmbr-secA \ + --net2 virtio,bridge=vmbr-secB \ + --net0 virtio,bridge=vmbr-serve +# swtpm for each workstation VM (Proxmox vTPM): +qm set --tpmstate0 :1,version=v2.0 +``` + +## Run order + +1. **Bring up the CA + RADIUS** on the serving host: + ```bash + cd serving/dot1x-bench + docker compose up -d step-ca + ./step-ca/bootstrap.sh # init CA, make a provisioner, issue the RADIUS cert + docker compose up -d freeradius + ``` + `bootstrap.sh` writes the **root** to `pki/root_ca.crt` — copy it to + `files/etc/dot1x/radius-ca.pem` for the image build (this is what the + supplicant uses to trust the RADIUS server). + +2. **Build a secure-path image** — `DISABLE_DOT1X=false` (CI var or + `-var disable_dot1x=false`), with `radius-ca.pem` = step-ca root and a + `nvmet_traddr` that resolves over the serving VLAN. + +3. **Enrol each workstation's TPM** (one-shot, on a provisioning network that can + reach step-ca) — see `enrol/tpm-provision.sh`: generates the key **in** the TPM + at `0x81010001`, builds a CSR with it, has step-ca sign it, writes the cert to + NV `0x01800001`. Each host gets a unique cert → unique hostnqn. + +4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec + plumbing per link) then run `hostapd authenticator/hostapd-wired-.conf`. + +5. **Boot the workstations** on the secure-path image and check the criteria + below. + +## Pass criteria (per booted workstation) + +```bash +wpa_cli -p /run/wpa_supplicant -i status | grep suppPortStatus # = Authorized +ip macsec show # macsec0: GCM-AES cipher, encrypt on, TX/RX SC +ip -s macsec show macsec0 # encrypted-pkts counters incrementing +ip -4 addr show macsec0 # DHCP lease ON macsec0 +cat /etc/nvme/hostnqn # unique per host: …:host- +findmnt / ; nvme list # NVMe root connected + verity-mapped, overlay writable +ps -o args= -C wpa_supplicant # @wpa_supplicant ALIVE after switch-root (invariant #3) +``` +Plus the **renewal** check (the reason for the network-manager port): leave it up +past the DHCP lease and confirm `macsec0` keeps its address and the link doesn't +drop (~6 s without MKA keepalives = dead link). On the authenticator, +`tcpdump -i ether proto 0x88e5` should show **only** MACsec frames after +auth — proof the wire is actually encrypted. + +## Multi-host point + +The whole reason for 2 VMs + the 820: one **identical** image, three **distinct** +TPM certs → three **distinct** hostnqns, each authenticating independently. That +verifies invariant #1 (no per-host state in the image) and lets you test NVMe +host-filtering per NQN. + +## Next step: real TPM attestation + +This starting solution *signs a TPM-generated CSR* — the key never leaves the +TPM, but step-ca doesn't yet *prove* TPM residency. The upgrade is step-ca's ACME +**`device-attest-01`** provisioner (EK/AK attestation): switch the enrolment from +`step ca sign` to `step ca acme` with a TPM attestation provisioner on the same +CA. That closes the "is the key really in a TPM" gap and strengthens SSP R-01. +See `step-ca/bootstrap.sh` (the commented `acme-attest` provisioner block). diff --git a/serving/dot1x-bench/authenticator/hostapd-wired.conf b/serving/dot1x-bench/authenticator/hostapd-wired.conf new file mode 100644 index 0000000..30fee89 --- /dev/null +++ b/serving/dot1x-bench/authenticator/hostapd-wired.conf @@ -0,0 +1,33 @@ +# hostapd wired 802.1X authenticator + MACsec, ONE per supplicant-facing link. +# Copy per link and set `interface=` to that link's NIC (e.g. eth1 for vmbr-secA). +# Run: hostapd -dd authenticator/hostapd-wired-.conf +# +# BENCH-VERIFY: wired+MACsec authenticator directives vary by hostapd build/version +# (CONFIG_MACSEC + CONFIG_DRIVER_WIRED must be compiled in). Confirm your hostapd +# was built with MACsec; `hostapd -v` / package notes. This is the half most +# likely to need tweaking on first bring-up. + +interface=eth1 +driver=wired + +# --- 802.1X (external RADIUS = FreeRADIUS on the serving host) --------------- +ieee8021x=1 +eap_server=0 +eapol_version=2 +use_pae_group_addr=1 # listen on 01:80:c2:00:00:03 (PAE) + +auth_server_addr=192.168.50.10 # serving host (FreeRADIUS) +auth_server_port=1812 +auth_server_shared_secret=testing123 # MUST match freeradius/clients.conf + .env RADIUS_SECRET + +# --- MACsec / MKA ----------------------------------------------------------- +# macsec_policy=1 requires MACsec; the CAK is derived from the EAP MSK and MKA +# negotiates the SAK. This hostapd is the MKA key server for the link. +macsec_policy=1 +macsec_integ_only=0 # confidentiality (encrypt), matches the supplicant +macsec_replay_protect=1 +macsec_replay_window=0 +mka_priority=128 # lower than the supplicant's default => we're key server + +# After MKA succeeds the kernel creates a macsec iface on top of `interface`. +# setup-link.sh bridges that to the serving uplink so DHCP/NVMe flow through. diff --git a/serving/dot1x-bench/authenticator/setup-link.sh b/serving/dot1x-bench/authenticator/setup-link.sh new file mode 100755 index 0000000..27a41ec --- /dev/null +++ b/serving/dot1x-bench/authenticator/setup-link.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# setup-link.sh +# +# Prep one authenticator link before starting hostapd on it. The authenticator is +# a 2-sided node: faces the workstation (hostapd + MACsec runs +# here), faces the serving VLAN (DHCP/NVMe/RADIUS). After MKA +# succeeds hostapd creates a macsec iface over ; the DECRYPTED +# traffic on that macsec iface must reach the serving network — we bridge it to +# . +# +# Run as root on the authenticator node (VM or inline box). BENCH-VERIFY the +# macsec iface name hostapd creates on your build (assumed "macsec0"). +set -euo pipefail + +SUP=${1:?usage: setup-link.sh } +UP=${2:?uplink nic required} +MACSEC=${3:-macsec0} # iface hostapd creates after MKA (override if different) +BR=${BR:-br-auth} + +ip link set "$SUP" up +ip link set "$UP" up + +# Allow EAPOL through any bridge in the path (reserved PAE multicast is dropped +# by default). The supplicant-facing side is a raw NIC for hostapd, so this is +# mainly for the uplink bridge. +ip link add name "$BR" type bridge 2>/dev/null || true +echo 8 > "/sys/class/net/$BR/bridge/group_fwd_mask" 2>/dev/null || true +ip link set "$UP" master "$BR" +ip link set "$BR" up + +echo "Authenticator link ready:" +echo " supplicant NIC : $SUP (start hostapd here)" +echo " uplink bridge : $BR <- $UP (serving VLAN)" +echo +echo "AFTER hostapd brings MKA up and creates '$MACSEC', bridge it to the uplink:" +echo " ip link set $MACSEC master $BR && ip link set $MACSEC up" +echo "(Do this once $MACSEC exists; automate with a hostapd 'macsec' up-hook if" +echo " your build supports one.)" diff --git a/serving/dot1x-bench/docker-compose.yml b/serving/dot1x-bench/docker-compose.yml new file mode 100644 index 0000000..6c2987f --- /dev/null +++ b/serving/dot1x-bench/docker-compose.yml @@ -0,0 +1,47 @@ +# ============================================================================= +# Secure-path bench: step-ca (fleet CA) + FreeRADIUS (EAP-TLS), on the SERVING +# host (the trusted side). The authenticator (hostapd-wired + MACsec) is NOT +# here — it runs on a node between each workstation and this host (see +# authenticator/). Host networking so FreeRADIUS sees RADIUS/1812 from the +# authenticator and step-ca is reachable for enrolment. +# +# cp .env.example .env # set CA_DNS / CA_PASSWORD / RADIUS_SECRET +# docker compose up -d step-ca && ./step-ca/bootstrap.sh && docker compose up -d freeradius +# +# Images are pulled from Docker Hub here; mirror them through your registry +# (like the serving stack does for nginx/alpine) if you don't have egress. +# ============================================================================= + +services: + # --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) -------- + step-ca: + image: smallstep/step-ca:latest + network_mode: host + restart: unless-stopped + environment: + # Auto-init on first start (smallstep image convention). bootstrap.sh adds + # the enrolment provisioner + issues the RADIUS server cert afterwards. + DOCKER_STEPCA_INIT_NAME: "Fleet Bench CA" + DOCKER_STEPCA_INIT_DNS_NAMES: "${CA_DNS:-ca.fleet.lab},localhost" + DOCKER_STEPCA_INIT_ADDRESS: ":${CA_PORT:-8443}" + DOCKER_STEPCA_INIT_PROVISIONER_NAME: "enrol" + DOCKER_STEPCA_INIT_PASSWORD_FILE: "/home/step/secrets/password" + volumes: + - ./pki/stepca:/home/step # CA state (root/intermediate/keys/db) — gitignored + - ./pki:/export # bootstrap.sh drops root_ca.crt here for the image/radius + + # --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) --- + freeradius: + image: freeradius/freeradius-server:latest + network_mode: host # RADIUS 1812/1813 from the authenticator + restart: unless-stopped + depends_on: + - step-ca + # -X = full debug to stdout; priceless for the first bring-up. Drop to plain + # `radiusd -f` once EAP-TLS is green. + command: ["radiusd", "-X"] + volumes: + - ./freeradius/clients.conf:/etc/raddb/clients.conf:ro + - ./freeradius/mods-available/eap:/etc/raddb/mods-available/eap:ro + # certs issued by bootstrap.sh: the RADIUS server cert/key + the CA root. + - ./pki/radius:/etc/raddb/certs/fleet:ro diff --git a/serving/dot1x-bench/enrol/tpm-provision.sh b/serving/dot1x-bench/enrol/tpm-provision.sh new file mode 100755 index 0000000..25cfd91 --- /dev/null +++ b/serving/dot1x-bench/enrol/tpm-provision.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# tpm-provision.sh +# +# One-shot enrolment for ONE workstation. Models the real flow (invariant #1: +# per-host identity is created at enrolment, never baked into the image): +# 1. generate a signing key INSIDE the TPM, persist it at handle 0x81010001 +# 2. build a CSR using that TPM key (private key never leaves the TPM) +# 3. have step-ca sign it (token-gated 'enrol' provisioner) +# 4. write the issued cert (DER) to TPM NV index 0x01800001 +# At boot the dracut hook reads the cert from NV and wpa_supplicant signs EAP-TLS +# with the persistent-handle key. hostnqn is later derived from the cert serial. +# +# Run on the workstation (booted into a provisioning env with tpm2-tools, an +# openssl TPM2 provider, and the `step` CLI) on a network that can reach step-ca. +# Works for swtpm (VMs) and a real TPM (820) — same /dev/tpmrm0 in-guest. +# +# Needs in this dir: root_ca.crt + ca-pw (copied from the serving host's +# serving/dot1x-bench/pki/{root_ca.crt,stepca/secrets/password}). +# +# *** BENCH-VERIFY (CLAUDE.md "Known bench-verify items"): the TPM key reference +# *** for openssl AND wpa_supplicant differs by distro build — OpenSSL TPM2 +# *** PROVIDER (handle:0x...) vs tpm2-pkcs11 URI. This script uses the provider +# *** form; switch to the engine/pkcs11 form if your build needs it, and keep it +# *** consistent with files/etc/dot1x/wpa_supplicant-dot1x.conf. +set -euo pipefail + +HOSTID=${1:?usage: tpm-provision.sh e.g. ws-vm-a} +HANDLE=${HANDLE:-0x81010001} +NVINDEX=${NVINDEX:-0x01800001} +export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" +CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443} +CA_URL="https://${CA_DNS}:${CA_PORT}" + +cd "$(dirname "$0")" +OUT=out; mkdir -p "$OUT" +[ -f root_ca.crt ] || { echo "missing root_ca.crt (copy from serving pki/)" >&2; exit 1; } +[ -f ca-pw ] || { echo "missing ca-pw (copy pki/stepca/secrets/password)" >&2; exit 1; } + +# --- 1. persistent TPM signing key ------------------------------------------ +if ! tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then + echo "==> creating TPM signing key, persisting at $HANDLE" + tpm2_createprimary -C o -G ecc -g sha256 -c "$OUT/primary.ctx" >/dev/null + tpm2_create -C "$OUT/primary.ctx" -G ecc256:ecdsa-sha256 \ + -u "$OUT/key.pub" -r "$OUT/key.priv" >/dev/null + tpm2_load -C "$OUT/primary.ctx" -u "$OUT/key.pub" -r "$OUT/key.priv" \ + -c "$OUT/key.ctx" >/dev/null + tpm2_evictcontrol -C o -c "$OUT/key.ctx" "$HANDLE" >/dev/null +else + echo "==> reusing existing persistent key at $HANDLE" +fi + +# --- 2. CSR signed by the TPM key (key stays in the TPM) --------------------- +echo "==> building CSR (CN=$HOSTID) with the TPM key via the openssl tpm2 provider" +openssl req -new -provider tpm2 -provider default \ + -key "handle:$HANDLE" \ + -subj "/O=fleet/CN=$HOSTID" \ + -out "$OUT/$HOSTID.csr" + +# --- 3. step-ca signs the CSR ----------------------------------------------- +echo "==> requesting cert from step-ca" +TOKEN=$(step ca token "$HOSTID" --provisioner enrol \ + --ca-url "$CA_URL" --root root_ca.crt \ + --provisioner-password-file ca-pw --not-after 720h) +step ca sign "$OUT/$HOSTID.csr" "$OUT/$HOSTID.pem" \ + --token "$TOKEN" --ca-url "$CA_URL" --root root_ca.crt +openssl x509 -in "$OUT/$HOSTID.pem" -outform DER -out "$OUT/$HOSTID.der" + +# --- 4. write cert into TPM NV ---------------------------------------------- +SZ=$(stat -c%s "$OUT/$HOSTID.der") +echo "==> writing $SZ-byte cert to NV $NVINDEX" +tpm2_nvundefine "$NVINDEX" -C o 2>/dev/null || true +tpm2_nvdefine "$NVINDEX" -C o -s "$SZ" -a 'ownerread|ownerwrite|authread|authwrite' >/dev/null +tpm2_nvwrite "$NVINDEX" -C o -i "$OUT/$HOSTID.der" + +SERIAL=$(openssl x509 -in "$OUT/$HOSTID.pem" -serial -noout | cut -d= -f2 | tr 'A-F' 'a-f') +echo "==> enrolled $HOSTID" +echo " cert serial : $SERIAL" +echo " boot hostnqn: nqn.2026-06.org.example.fleet:host-$SERIAL" +echo " key @ $HANDLE, cert @ NV $NVINDEX — the secure-path image will read these." diff --git a/serving/dot1x-bench/enrol/tpm-selftest.sh b/serving/dot1x-bench/enrol/tpm-selftest.sh new file mode 100755 index 0000000..bd28059 --- /dev/null +++ b/serving/dot1x-bench/enrol/tpm-selftest.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# tpm-selftest.sh — non-destructive check that a workstation's TPM can do +# everything the secure path needs, BEFORE turning on disable_dot1x=false. +# +# Run as root on a booted workstation (the open-port test image is fine — it +# ships the same tooling). Uses SCRATCH handle/NV and cleans them up, so it never +# touches the real 0x81010001 / 0x01800001. Pass --use-real-handles to exercise +# the actual locations instead (still cleaned up unless --keep). +# +# It validates, in order: +# 1. TPM present + tpm2-tools work +# 2. persistent key create + evict (needed to put the EAP-TLS key at the handle) +# 3. NV define/write/read roundtrip (the cert store the boot hook reads) +# 4. an OpenSSL TPM2 provider/engine exists <-- the usual missing piece +# 5. a CSR + signature USING the TPM key (proves EAP-TLS client auth can work) +# 6. wpa_supplicant build info (macsec driver present?) +set -u + +USE_REAL=0; KEEP=0 +for a in "$@"; do case "$a" in + --use-real-handles) USE_REAL=1 ;; + --keep) KEEP=1 ;; + *) echo "usage: tpm-selftest.sh [--use-real-handles] [--keep]" >&2; exit 2 ;; +esac; done + +export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" +if [ "$USE_REAL" = 1 ]; then HANDLE=0x81010001; NV=0x01800001 +else HANDLE=0x81008000; NV=0x01500000; fi + +WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +no() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; SKIP=$((SKIP+1)); } + +# shellcheck disable=SC2329 # invoked indirectly via trap +cleanup() { + [ "$KEEP" = 1 ] && { echo "(--keep: leaving $HANDLE / $NV / $WORK in place)"; return; } + tpm2_evictcontrol -C o -c "$HANDLE" >/dev/null 2>&1 || true + tpm2_nvundefine "$NV" -C o >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +echo "== TPM self-test (handle=$HANDLE nv=$NV) ==" + +# --- 1. TPM present --------------------------------------------------------- +if [ -c /dev/tpmrm0 ] || [ -c /dev/tpm0 ]; then ok "TPM device present"; else + no "no /dev/tpmrm0 or /dev/tpm0 — VM needs a vTPM (qm set --tpmstate0 :1,version=v2.0)" + echo; echo "Cannot continue without a TPM."; exit 1 +fi +if command -v tpm2_getcap >/dev/null && tpm2_getcap properties-fixed >/dev/null 2>&1; then + ok "tpm2-tools talk to the TPM" +else + no "tpm2-tools / TPM access failed"; exit 1 +fi + +# pre-clean the scratch locations in case a previous run aborted +tpm2_evictcontrol -C o -c "$HANDLE" >/dev/null 2>&1 || true +tpm2_nvundefine "$NV" -C o >/dev/null 2>&1 || true + +# --- 2. persistent signing key ---------------------------------------------- +if tpm2_createprimary -C o -G ecc -g sha256 -c "$WORK/prim.ctx" >/dev/null 2>&1 \ + && tpm2_create -C "$WORK/prim.ctx" -G ecc256:ecdsa-sha256 -u "$WORK/k.pub" -r "$WORK/k.priv" >/dev/null 2>&1 \ + && tpm2_load -C "$WORK/prim.ctx" -u "$WORK/k.pub" -r "$WORK/k.priv" -c "$WORK/k.ctx" >/dev/null 2>&1 \ + && tpm2_evictcontrol -C o -c "$WORK/k.ctx" "$HANDLE" >/dev/null 2>&1 \ + && tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then + ok "created + persisted an ECC signing key at $HANDLE" +else + no "could not create/persist a key at $HANDLE" +fi + +# --- 3. NV roundtrip (the cert store the boot hook reads) ------------------- +echo "fleet-tpm-selftest-$(date +%s 2>/dev/null || echo x)" > "$WORK/blob" +SZ=$(stat -c%s "$WORK/blob") +if tpm2_nvdefine "$NV" -C o -s "$SZ" -a 'ownerread|ownerwrite|authread|authwrite' >/dev/null 2>&1 \ + && tpm2_nvwrite "$NV" -C o -i "$WORK/blob" >/dev/null 2>&1 \ + && tpm2_nvread "$NV" -C o -o "$WORK/blob.out" >/dev/null 2>&1 \ + && cmp -s "$WORK/blob" "$WORK/blob.out"; then + ok "NV define/write/read roundtrip at $NV (matches what the boot hook does)" +else + no "NV roundtrip at $NV failed" +fi + +# --- 4. OpenSSL TPM2 provider / engine -------------------------------------- +TPM_OSSL="" +if openssl list -providers 2>/dev/null | grep -qi tpm2; then + TPM_OSSL=provider; ok "OpenSSL TPM2 PROVIDER present (tpm2-openssl)" +elif openssl engine -t tpm2tss 2>/dev/null | grep -qi available; then + TPM_OSSL=engine; ok "OpenSSL TPM2 ENGINE present (tpm2-tss-engine)" +else + no "no OpenSSL TPM2 provider OR engine — install 'tpm2-openssl' (provider) in the image" + echo " (this is the likely gap: tpm2-tss alone does NOT give openssl TPM access)" +fi + +# --- 5. CSR + signature USING the TPM key (the EAP-TLS make-or-break) -------- +if [ -n "$TPM_OSSL" ]; then + CSR_OK=1 + if [ "$TPM_OSSL" = provider ]; then + openssl req -new -provider tpm2 -provider default -key "handle:$HANDLE" \ + -subj "/O=fleet/CN=tpm-selftest" -out "$WORK/test.csr" >/dev/null 2>&1 || CSR_OK=0 + else + openssl req -new -engine tpm2tss -keyform engine -key "$HANDLE" \ + -subj "/O=fleet/CN=tpm-selftest" -out "$WORK/test.csr" >/dev/null 2>&1 || CSR_OK=0 + fi + if [ "$CSR_OK" = 1 ] && openssl req -in "$WORK/test.csr" -verify -noout >/dev/null 2>&1; then + ok "built + self-verified a CSR signed BY the TPM key ($TPM_OSSL) — EAP-TLS can use it" + else + no "openssl could not sign a CSR with the TPM key via the $TPM_OSSL" + echo " -> the key reference method in wpa_supplicant-dot1x.conf needs adjusting" + fi +else + skip "CSR/sign test (no openssl TPM integration to test with)" +fi + +# --- 6. wpa_supplicant build info ------------------------------------------- +if command -v wpa_supplicant >/dev/null; then + V=$(wpa_supplicant -v 2>&1 | head -1) + ok "wpa_supplicant present: $V" + echo " NOTE: confirm it was built with macsec_linux + the matching TPM key" + echo " backend; full EAP-TLS/MKA can only be proven against the authenticator." +else + no "wpa_supplicant not found" +fi + +echo +echo "== summary: $PASS pass, $FAIL fail, $SKIP skip ==" +[ "$FAIL" = 0 ] && echo "TPM half looks ready — proceed to the authenticator/RADIUS bench." \ + || echo "Fix the FAILs above before enabling disable_dot1x=false." +exit "$FAIL" diff --git a/serving/dot1x-bench/freeradius/clients.conf b/serving/dot1x-bench/freeradius/clients.conf new file mode 100644 index 0000000..c1c6392 --- /dev/null +++ b/serving/dot1x-bench/freeradius/clients.conf @@ -0,0 +1,9 @@ +# RADIUS clients = the authenticator(s) (hostapd). The `secret` MUST match each +# hostapd's auth_server_shared_secret (RADIUS_SECRET in .env). Scope ipaddr to +# the serving VLAN the authenticator's uplink sits on. +client authenticator { + ipaddr = 192.168.50.0/24 + secret = testing123 + nas_type = other + require_message_authenticator = yes +} diff --git a/serving/dot1x-bench/freeradius/mods-available/eap b/serving/dot1x-bench/freeradius/mods-available/eap new file mode 100644 index 0000000..9b6a8bd --- /dev/null +++ b/serving/dot1x-bench/freeradius/mods-available/eap @@ -0,0 +1,37 @@ +# EAP module, trimmed to EAP-TLS for the fleet bench. Replaces the stock eap mod. +# EAP-TLS is mutual-cert by definition: the client (TPM cert) is validated against +# ca_file (= step-ca root); the server presents server.pem so the supplicant can +# validate it against the SAME root baked into the image as radius-ca.pem. On +# success FreeRADIUS exports the MSK (MS-MPPE keys) → the authenticator derives +# the MACsec CAK (MKA). No `users` entry is needed for cert-only auth. +eap { + default_eap_type = tls + timer_expire = 60 + ignore_unknown_eap_types = no + cisco_accounting_username_bug = no + max_sessions = ${max_requests} + + tls-config tls-common { + certificate_file = /etc/raddb/certs/fleet/server.pem + private_key_file = /etc/raddb/certs/fleet/server.key + # private_key_password unset — step-ca issues an unencrypted key here. + ca_file = /etc/raddb/certs/fleet/ca.pem # step-ca root: validates client (TPM) certs + dh_file = ${certdir}/dh # shipped by the image's bootstrap + random_file = /dev/urandom + + # BENCH-VERIFY: many wired-MACsec supplicants negotiate EAP-TLS 1.2; bump + # to 1.3 once confirmed working. Keep min>=1.2. + tls_min_version = "1.2" + tls_max_version = "1.2" + cipher_list = "DEFAULT" + cipher_server_preference = no + ecdh_curve = "" + + # OCSP/CRL against step-ca for revocation can be enabled here later + # (ocsp { enable = yes ... }) — bench accepts any cert signed by the root. + } + + tls { + tls = tls-common + } +} diff --git a/serving/dot1x-bench/step-ca/bootstrap.sh b/serving/dot1x-bench/step-ca/bootstrap.sh new file mode 100755 index 0000000..cc81d44 --- /dev/null +++ b/serving/dot1x-bench/step-ca/bootstrap.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# bootstrap.sh — one-time setup of the bench CA after `docker compose up -d step-ca`. +# +# 1. wait for step-ca, export its root to pki/root_ca.crt (-> image radius-ca.pem) +# 2. ensure an enrolment provisioner exists (signs each host's TPM CSR) +# 3. issue the RADIUS server cert/key into pki/radius/ (FreeRADIUS mounts it) +# +# Runs the `step` CLI against the running CA. Flags vary a little across step +# versions — BENCH-VERIFY against your `step version` and adjust. Reads .env. +set -euo pipefail + +cd "$(dirname "$0")/.." +[ -f .env ] && . ./.env +CA_DNS=${CA_DNS:-ca.fleet.lab} +CA_PORT=${CA_PORT:-8443} +CA_URL="https://${CA_DNS}:${CA_PORT}" +PKI=pki +mkdir -p "$PKI/radius" + +# The CA's password (auto-init wrote it from this file in the container volume). +PW="$PKI/stepca/secrets/password" +[ -f "$PW" ] || { echo "CA not initialised yet ($PW missing) — is step-ca up?" >&2; exit 1; } + +step() { docker compose exec -T step-ca step "$@"; } + +# --- 1. export the root (becomes files/etc/dot1x/radius-ca.pem) -------------- +echo "==> exporting CA root" +docker compose exec -T step-ca cat /home/step/certs/root_ca.crt > "$PKI/root_ca.crt" +FP=$(step certificate fingerprint "$PKI/root_ca.crt" 2>/dev/null || \ + openssl x509 -in "$PKI/root_ca.crt" -noout -fingerprint -sha256 | cut -d= -f2) +echo " root fingerprint: $FP" +echo " copy to: files/etc/dot1x/radius-ca.pem (image trust anchor)" + +# --- 2. enrolment provisioner (idempotent) ---------------------------------- +# JWK provisioner 'enrol' is created by auto-init (DOCKER_STEPCA_INIT_PROVISIONER_NAME). +# Enrolment authorizes a CSR with a one-time token: +# step ca token --provisioner enrol → passed to `step ca sign` +# That token-gated sign is what enrol/tpm-provision.sh uses. +echo "==> provisioners:" +step ca provisioner list --ca-url "$CA_URL" --root "$PKI/root_ca.crt" \ + | grep -E '"(name|type)"' || true + +# To UPGRADE to TPM attestation later, add an ACME attestation provisioner and +# switch enrolment to device-attest-01 (key residency is then PROVEN, not trusted): +# step ca provisioner add acme-attest --type ACME \ +# --challenge device-attest-01 \ +# --attestation-format tpm \ +# --attestation-root +# (then `step ca acme` / an ACME client on the host instead of `step ca sign`.) + +# --- 3. RADIUS server cert (FreeRADIUS serves this; supplicant validates it) -- +echo "==> issuing RADIUS server cert (radius.${CA_DNS#ca.})" +RADIUS_CN="radius.${CA_DNS#ca.}" +step ca certificate "$RADIUS_CN" \ + "$PKI/radius/server.pem" "$PKI/radius/server.key" \ + --ca-url "$CA_URL" --root "$PKI/root_ca.crt" \ + --provisioner enrol --provisioner-password-file "$PW" \ + --not-after 720h --force +cp "$PKI/root_ca.crt" "$PKI/radius/ca.pem" +echo "==> done. FreeRADIUS will read pki/radius/{server.pem,server.key,ca.pem}." +echo " Next: cp $PKI/root_ca.crt ../../files/etc/dot1x/radius-ca.pem && rebuild secure-path image."