Adding dot1x support

This commit is contained in:
Audiobuzz
2026-06-22 20:50:48 +00:00
parent 1f62e5ab17
commit dea7ca451a
13 changed files with 592 additions and 0 deletions
+7
View File
@@ -39,6 +39,8 @@ serving/ Lab serving stack: dnsmasq + nginx + S
CI-built + pulled; see .gitea/workflows/) CI-built + pulled; see .gitea/workflows/)
serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA
(kiosk endpoint; nginx reverse-proxies /kiosk/) (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 .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI
docs/ Design PDF and supporting notes 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 `group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port
`Authorized`, macsec0 addressed, NVMe namespace connected, verity root `Authorized`, macsec0 addressed, NVMe namespace connected, verity root
mapped, overlay writable, supplicant alive after switch-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 Any change to the dracut module, supplicant config, or cmdline must pass
this before merge. Template-only changes need `packer validate` + this before merge. Template-only changes need `packer validate` +
+11
View File
@@ -620,6 +620,17 @@ smoke test.
- **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`). - **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`).
Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0). 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 - **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot
by the serving dnsmasq — drop the hardcoded `192.168.50.10`. by the serving dnsmasq — drop the hardcoded `192.168.50.10`.
- **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM - **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM
+5
View File
@@ -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
+10
View File
@@ -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
+126
View File
@@ -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>/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 <vmid-A> --net1 virtio,bridge=vmbr-secA
qm set <vmid-B> --net1 virtio,bridge=vmbr-secB
# Authenticator VM: one nic per workstation link + one on the serving bridge:
qm set <vmid-auth> --net1 virtio,bridge=vmbr-secA \
--net2 virtio,bridge=vmbr-secB \
--net0 virtio,bridge=vmbr-serve
# swtpm for each workstation VM (Proxmox vTPM):
qm set <vmid-A> --tpmstate0 <storage>: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-<link>.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 <nic> 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-<cert-serial>
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 <link> 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).
@@ -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-<link>.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.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# setup-link.sh <supplicant-nic> <uplink-nic>
#
# Prep one authenticator link before starting hostapd on it. The authenticator is
# a 2-sided node: <supplicant-nic> faces the workstation (hostapd + MACsec runs
# here), <uplink-nic> faces the serving VLAN (DHCP/NVMe/RADIUS). After MKA
# succeeds hostapd creates a macsec iface over <supplicant-nic>; the DECRYPTED
# traffic on that macsec iface must reach the serving network — we bridge it to
# <uplink-nic>.
#
# 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 <supplicant-nic> <uplink-nic>}
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.)"
+47
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# tpm-provision.sh <host-id>
#
# 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 <host-id> 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."
+129
View File
@@ -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 <id> --tpmstate0 <stor>: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"
@@ -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
}
@@ -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
}
}
+61
View File
@@ -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 <host-id> --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 <tpm-vendor-EK-roots.pem>
# (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."