Adding dot1x support
This commit is contained in:
@@ -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` +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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.)"
|
||||
@@ -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
|
||||
Executable
+79
@@ -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."
|
||||
Executable
+129
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+61
@@ -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."
|
||||
Reference in New Issue
Block a user