tpm-enrol: move signing key off the EK slot 0x81010001 → 0x81000100

Bench (Proxmox vTPM, host cygnus-cruiser-99) confirmed via tpm2_readpublic that
0x81010001 — the handle our design used for the fleet signing key — is the
TCG-standard RSA Endorsement Key slot: the object there is the swtpm-provisioned
EK (restricted|decrypt|adminwithpolicy, RSA-2048/AES128CFB, canonical EK
authPolicy 837197…, EK cert at NV 0x1c00002). Most firmware/swtpm TPMs occupy
that slot, so EnsureKey could never create our key there — it errored reading
the EK as ECC and enrolment died before the CSR/POST.

Move the fleet signing key to 0x81000100, a non-reserved owner persistent
handle (clear of EK 0x81010001/2 and SRK 0x81000001/2). Threaded through:
  - fleet-enrol -handle default + new rd.dot1x.handle= cmdline knob (baked by
    extract-artifacts.sh, overridable via SMBIOS)
  - files/etc/dot1x/wpa_supplicant-dot1x.conf private_key handle
  - the dracut hook, bench tpm-provision.sh / tpm-selftest.sh
  - CLAUDE.md invariant #1 + design/SSP docs + TODO #11
NV index 0x01800001 (owner range) is unchanged.

EnsureKey hardening: refuse to evict any restricted-decrypt object (EK/SRK) as
a safety net — never clobber the TPM endorsement/storage identity (also
protects the future EK/AK attestation path). Still self-heals a genuine stale
signing key. New regression TestEnsureKeyRefusesToEvictProtectedKey; the
stale-key regression and handle constants moved to 0x81000100.

Design note records the IDevID (0x81020001) option for the later EK/AK
attestation work. Follow-up items logged: content-aware NV presence check
(verify the cert chains to the fleet CA, so leftover/foreign NV can't suppress
enrolment) and a de-enrol/TPM-reset procedure.
This commit is contained in:
Audiobuzz
2026-07-02 12:19:59 +00:00
parent 4c55ed4b26
commit 4d6f038e3c
17 changed files with 201 additions and 61 deletions
+7 -4
View File
@@ -103,10 +103,13 @@ instead of working around it.
1. **No per-host state in any artifact, ever.** No hostnames, keys, certs, 1. **No per-host state in any artifact, ever.** No hostnames, keys, certs,
MACs, or per-host config in the initramfs, rootfs, UKI, or OCI image. MACs, or per-host config in the initramfs, rootfs, UKI, or OCI image.
All host identity derives from the TPM at runtime (key at persistent All host identity derives from the TPM at runtime (signing key at persistent
handle `0x81010001`, cert at NV index `0x01800001`, hostnqn derived from handle `0x81000100` — a **non-reserved owner handle**, deliberately NOT the
the cert serial). If a change needs per-host data, it belongs in the EK slot `0x81010001` nor the SRK `0x81000001`, which firmware/swtpm
enrolment flow, not the image. provisioning already occupy; cert at NV index `0x01800001`, hostnqn derived
from the cert serial). If a change needs per-host data, it belongs in the
enrolment flow, not the image. (The handle is overridable via
`rd.dot1x.handle=` / `fleet-enrol -handle`.)
1. **The hook ordering in `dot1x-macsec-up.sh` is load-bearing.** 1. **The hook ordering in `dot1x-macsec-up.sh` is load-bearing.**
NV-cert extraction → supplicant start → wait for `Authorized` + macsec0 → NV-cert extraction → supplicant start → wait for `Authorized` + macsec0 →
DHCP **on macsec0** → hostnqn derivation. Never DHCP before auth DHCP **on macsec0** → hostnqn derivation. Never DHCP before auth
+1 -1
View File
@@ -137,7 +137,7 @@ first non-loopback interface is used. Test builds prepend `rd.dot1x=0`.
(needs a reasonably recent systemd; otherwise publish data and hash as (needs a reasonably recent systemd; otherwise publish data and hash as
two separate namespaces and drop the hash-offset option). two separate namespaces and drop the hash-offset option).
3. **TPM key reference**: confirm wpa_supplicant accepts 3. **TPM key reference**: confirm wpa_supplicant accepts
`private_key="handle:0x81010001"` via the OpenSSL tpm2 provider; if not, `private_key="handle:0x81000100"` via the OpenSSL tpm2 provider; if not,
switch to the tpm2-pkcs11 option in the supplicant config. switch to the tpm2-pkcs11 option in the supplicant config.
4. **CONFIG_MACSEC** in the shipped wpa_supplicant build. 4. **CONFIG_MACSEC** in the shipped wpa_supplicant build.
5. **Pivot survival**: `@wpa_supplicant` survives switch-root; MKA continuity 5. **Pivot survival**: `@wpa_supplicant` survives switch-root; MKA continuity
+54 -21
View File
@@ -766,7 +766,7 @@ smoke test.
returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an
**isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped + **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 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 `0x81000100` + writes the cert to NV `0x01800001`. Starting solution **signs a
CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the 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: 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. hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version.
@@ -775,7 +775,7 @@ smoke test.
capability check (run on the booted open-port image, no `disable_dot1x` needed) capability check (run on the booted open-port image, no `disable_dot1x` needed)
to de-risk the TPM half before the full path. Likely first finding: no openssl to de-risk the TPM half before the full path. Likely first finding: no openssl
TPM2 **provider** in the image (`tpm2-tss` ≠ the provider) — add `tpm2-openssl`. TPM2 **provider** in the image (`tpm2-tss` ≠ the provider) — add `tpm2-openssl`.
- **Enrolment of the per-host identity** (key@`0x81010001` + cert@NV) is its own - **Enrolment of the per-host identity** (key@`0x81000100` + cert@NV) is its own
design — see **#11** (TOFU + OOB fingerprint broker, pre-auth permit-set, design — see **#11** (TOFU + OOB fingerprint broker, pre-auth permit-set,
provisioning UKI). `tpm-provision.sh` is the manual bench stand-in for it. provisioning UKI). `tpm-provision.sh` is the manual bench stand-in for it.
- **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
@@ -968,7 +968,7 @@ enrolls.
every NIC/GPU option ROM is covered by your keys — clearing MS db can break every NIC/GPU option ROM is covered by your keys — clearing MS db can break
signed **option ROMs** (NIC PXE/HTTP-boot, GPU init), bricking the boot path. signed **option ROMs** (NIC PXE/HTTP-boot, GPU init), bricking the boot path.
- **PCR 7 check:** enrollment changes the Secure Boot measurement in PCR 7; - **PCR 7 check:** enrollment changes the Secure Boot measurement in PCR 7;
confirm nothing the fleet seals to the TPM (the EAP-TLS key at `0x81010001`, confirm nothing the fleet seals to the TPM (the EAP-TLS key at `0x81000100`,
NV cert) is PCR-7-policy-bound before relying on this. NV cert) is PCR-7-policy-bound before relying on this.
**Decision before building:** auto-enroll trades assurance for convenience and is **Decision before building:** auto-enroll trades assurance for convenience and is
@@ -1007,7 +1007,7 @@ it talks to `/dev/tpmrm0` DIRECTLY (via `transport.FromReadWriter` on an
`os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment `os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment
path needs **no tpm2-tools key-gen / no openssl tpm2 provider / no curl** in the path needs **no tpm2-tools key-gen / no openssl tpm2 provider / no curl** in the
image (removes the biggest bench risk — the provider `handle:` key-ref). It image (removes the biggest bench risk — the provider `handle:` key-ref). It
ensures the ECDSA key at `0x81010001`, builds the CSR, POSTs CSR+context to ensures the ECDSA key at `0x81000100`, builds the CSR, POSTs CSR+context to
`rd.fleet.enrol_url`, shows the broker fingerprint on Plymouth, polls `/status`, `rd.fleet.enrol_url`, shows the broker fingerprint on Plymouth, polls `/status`,
and writes the leaf cert to NV `0x01800001`. The dracut hook `tpm-enrol.sh` and writes the leaf cert to NV `0x01800001`. The dracut hook `tpm-enrol.sh`
(initqueue/settled prio 25) is now a thin GATE: empty-NV check (`tpm2_nvread`, (initqueue/settled prio 25) is now a thin GATE: empty-NV check (`tpm2_nvread`,
@@ -1038,22 +1038,55 @@ ECDSA sign → CSR verify → chunked NV write → re-enrol) against the go-tpm
simulator** (separate `files/enrol/simtest/` module so the CGO simulator never simulator** (separate `files/enrol/simtest/` module so the CGO simulator never
enters the shipped build). Both run in CI. enters the shipped build). Both run in CI.
**BENCH FINDING 2026-07-02 (FIXED, commit c574fa4) — stale non-ECC key at the **BENCH FINDING 2026-07-02 (FIXED) — the signing-key handle `0x81000100` was the
handle.** First real-TPM boot (host `cygnus-cruiser-99`, `rd.dot1x=0`) failed: EK slot; moved to `0x81000100`.** First real-TPM boot (host `cygnus-cruiser-99`,
`fleet-enrol: ensure key at 0x81010001: key at handle is not ECC: did not contain Proxmox vTPM, `rd.dot1x=0`) failed: `fleet-enrol: ensure key at 0x81010001: key at
eccDetail (selector value was 1)`. The host already had an **RSA** key persisted at handle is not ECC: did not contain eccDetail (selector value was 1)`. `EnsureKey`
`0x81010001` (from an older tpm2-tools/openssl provisioning path); `EnsureKey` died reading an existing RSA object as ECC → enrolment died before the CSR/POST, so
fatally errored reading it as ECC → enrolment died before the CSR/POST, so the the splash flashed "FIRST-BOOT TPM ENROLMENT" then the test system booted with **no
splash flashed "FIRST-BOOT TPM ENROLMENT" then the test system booted with **no fingerprint and nothing at the broker**. Root cause (confirmed via `tpm2_readpublic`
fingerprint and nothing at the broker**. Fix: `EnsureKey` now **evicts a under `enforcing=0`): **`0x81000100` is the TCG-standard RSA Endorsement Key handle**
wrong-type/unusable key and recreates** a fresh ECC key (safe on the enrol path — — the object there was the swtpm-provisioned **EK** (`restricted|decrypt|adminwithpolicy`,
empty NV, no cert references the old key); a valid ECC key is still reused. RSA-2048/AES128CFB, canonical EK authPolicy `837197674484b3f8…`), with its EK cert at
Regression `simtest/TestEnsureKeyEvictsStaleNonECC` (pre-plants an RSA key). **Needs NV `0x1c00002`. Our design squatted the EK slot fleet-wide (most firmware/swtpm TPMs
a rebuild + re-boot to confirm on the bench** (the sim proves the recovery, not the have their EK there), so enrolment could never create our key.
device). ⚠ **Don't hand-run `sudo fleet-enrol` on the booted STIG host to test** — - **Fix (commit c574fa4 + follow-up):** the fleet signing key moved to
the confined admin domain (`sysadm_t`) is SELinux-**denied** write to **`0x81000100`** (non-reserved owner handle; threaded through `fleet-enrol
`/dev/tpmrm0` (`avc: denied … tpm_device_t`), so it fails before the TPM regardless; -handle` default, `rd.dot1x.handle=` cmdline knob baked by `extract-artifacts.sh`,
the faithful path is the **initramfs** run (reboot) which is unconfined. `wpa_supplicant-dot1x.conf`, the bench scripts, invariant #1, and the design docs).
NV `0x01800001` (owner range) is unchanged.
- **EnsureKey hardening:** (i) evicts a genuine *stale signing key* at the handle
and recreates (self-heal for old provisioning leftovers); (ii) **refuses to evict
any restricted-decrypt object (EK/SRK)** as a safety net — never clobber the TPM's
endorsement/storage identity (also protects the future EK/AK attestation).
Regressions: `simtest/TestEnsureKeyEvictsStaleNonECC` + `TestEnsureKeyRefusesToEvictProtectedKey`.
- **IDevID note (future):** if EK/AK attestation (`device-attest-01`, §"Stronger
assurance") lands, the natural home for a TPM device-identity key is the TCG
**IDevID** handle `0x81020001` (LDevID `0x81030001`) — see tpm-enrolment-design.md.
Not adopted for v1 (implies the IDevID template/cert conventions).
- **Also seen — leftover NV suppresses enrolment:** a *different* host (the 820)
booted `tpm-enrol: client cert present at NV 0x01800001 — normal boot` with nothing
enrolled this session — leftover NV state (from an earlier `tpm-provision.sh` /
`tpm-selftest.sh --use-real-handles`) **persists in the hardware TPM** across
reinstalls. Our presence check is content-blind (index defined ⇒ "enrolled"), so
foreign/stale NV silently skips enrolment (→ secure-path auth would then fail). See
the **content-aware NV check** item below.
- **Needs a rebuild + re-boot to confirm** (the sim proves the ceremony, not the
device). ⚠ **Don't hand-run `sudo fleet-enrol` on the booted STIG host** — the
confined admin domain (`sysadm_t`) is SELinux-**denied** write to `/dev/tpmrm0`
(`avc: denied … tpm_device_t`); the faithful path is the **initramfs** run
(reboot, unconfined), or `enforcing=0` for hand-run TPM inspection.
**FOLLOW-UP HARDENING (from the two findings above, not yet built):**
- **Content-aware NV presence check** — read NV `0x01800001`, parse as X.509, verify
it **chains to the fleet CA** (bundled `radius-ca.pem`); only then treat as enrolled,
else enrol + overwrite (`WriteNVCert` already undefine+redefines). Cleanest as a
`fleet-enrol -check-only` mode (no network) the hook calls — one content-aware source
of truth, replacing the hook's blind `tpm2_nvread` and the binary's `NVPresent`
(defined-only). Stops leftover/foreign NV from suppressing enrolment.
- **De-enrol / TPM-reset procedure** for re-testing: `tpm2_nvundefine 0x01800001 -C o`
(+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or
BIOS "Clear TPM". Document in the bench README.
**BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM + **BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM +
swtpm on the target (the simulator proves the ceremony, not the device) — **the swtpm on the target (the simulator proves the ceremony, not the device) — **the
@@ -1101,7 +1134,7 @@ Verification found + fixed two things the `mock` path had hidden:
FreeRADIUS (next, before host enrolment). FreeRADIUS (next, before host enrolment).
How a host gets its **802.1X EAP-TLS client identity** the first time — the key at How a host gets its **802.1X EAP-TLS client identity** the first time — the key at
TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come TPM handle `0x81000100` + cert at NV `0x01800001` that invariant #1 says must come
from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot* from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot*
PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI + PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI +
isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**, isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**,
+1 -1
View File
@@ -82,7 +82,7 @@ boot→secure dynamic VLAN. Consequences:
**Per-host 802.1X identity is created by an enrolment flow, never baked in** **Per-host 802.1X identity is created by an enrolment flow, never baked in**
(invariant #1). Chosen pattern: **trust-on-first-use with out-of-band fingerprint (invariant #1). Chosen pattern: **trust-on-first-use with out-of-band fingerprint
approval** — on an empty TPM the host generates a key *in* the TPM approval** — on an empty TPM the host generates a key *in* the TPM
(`0x81010001`), submits a CSR to an enrolment **broker** (reachable via the same (`0x81000100`), submits a CSR to an enrolment **broker** (reachable via the same
pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker → pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker →
step-ca signer), and shows a fingerprint on the Plymouth splash; an admin step-ca signer), and shows a fingerprint on the Plymouth splash; an admin
cross-checks it and approves; the signed cert is written to NV `0x01800001` and cross-checks it and approves; the signed cert is written to NV `0x01800001` and
+1 -1
View File
@@ -20,7 +20,7 @@ These are consequences of the CLAUDE.md invariants, not preferences:
stateless and buffers only in RAM (best-effort). stateless and buffers only in RAM (best-effort).
2. **Identity from the TPM, never baked.** No per-host state in any artifact 2. **Identity from the TPM, never baked.** No per-host state in any artifact
(invariant #1). The agent derives identity at runtime from the **TPM-held (invariant #1). The agent derives identity at runtime from the **TPM-held
cert** (key `0x81010001`, cert NV `0x01800001`) — the same identity used for cert** (key `0x81000100`, cert NV `0x01800001`) — the same identity used for
802.1X EAP-TLS and the NVMe hostnqn. No enrollment token in the image. 802.1X EAP-TLS and the NVMe hostnqn. No enrollment token in the image.
3. **Pull-based control, no inbound surface.** The boot LAN is locked down and 3. **Pull-based control, no inbound surface.** The boot LAN is locked down and
segmented; we do not open an always-on inbound control port on hosts. Hosts segmented; we do not open an always-on inbound control port on hosts. Hosts
+1 -1
View File
@@ -99,7 +99,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81010001`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) | | Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81000100`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) |
| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled; admin **confined to `staff_u`** with a sudo `sysadm_r`/`sysadm_t` role/type transition (SELinux context elevates on privilege use). | Partial | R-05 | | Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled; admin **confined to `staff_u`** with a sudo `sysadm_r`/`sysadm_t` role/type transition (SELinux context elevates on privilege use). | Partial | R-05 |
| Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — | | Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — |
| No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — | | No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — |
+1 -1
View File
@@ -90,7 +90,7 @@ manifest. One commit = one release = one digest = one manifest entry.
hash and STIG kernel parameters). No GRUB. hash and STIG kernel parameters). No GRUB.
3. **Initramfs (dracut)** brings up the NIC and performs **802.1X EAP-TLS** 3. **Initramfs (dracut)** brings up the NIC and performs **802.1X EAP-TLS**
network authentication using a **private key held in the TPM** (persistent network authentication using a **private key held in the TPM** (persistent
handle `0x81010001`); the client certificate is read from TPM NV index handle `0x81000100`); the client certificate is read from TPM NV index
`0x01800001`. `0x01800001`.
4. On authorisation, **MACsec** is established on the access link (link-layer 4. On authorisation, **MACsec** is established on the access link (link-layer
encryption/integrity); the host then obtains an IP **on the MACsec interface** encryption/integrity); the host then obtains an IP **on the MACsec interface**
+25 -5
View File
@@ -8,7 +8,7 @@ end-to-end *minus* the human-approval queue; the only unbuilt piece is the
**enrolment broker**. **enrolment broker**.
How a diskless host acquires — and keeps — its **one cryptographic identity**: a How a diskless host acquires — and keeps — its **one cryptographic identity**: a
signing key at TPM handle `0x81010001` and a cert at NV index `0x01800001`. signing key at TPM handle `0x81000100` and a cert at NV index `0x01800001`.
Invariant #1 forbids baking any per-host state into the image, so this identity Invariant #1 forbids baking any per-host state into the image, so this identity
**must** be minted at enrolment, at runtime, from the TPM. That single identity is **must** be minted at enrolment, at runtime, from the TPM. That single identity is
then reused for everything: **802.1X EAP-TLS**, the **NVMe hostnqn** (derived from then reused for everything: **802.1X EAP-TLS**, the **NVMe hostnqn** (derived from
@@ -28,7 +28,7 @@ separate box — is what makes the two designs one coherent identity story.
## Design philosophy (forced by the architecture invariants) ## Design philosophy (forced by the architecture invariants)
1. **One identity, minted once, reused everywhere.** Key `0x81010001` + cert 1. **One identity, minted once, reused everywhere.** Key `0x81000100` + cert
NV `0x01800001` = 802.1X identity = NVMe hostnqn (`…:host-<cert-serial>`) = NV `0x01800001` = 802.1X identity = NVMe hostnqn (`…:host-<cert-serial>`) =
mTLS client identity. There is no second secret. (Invariant #1.) mTLS client identity. There is no second secret. (Invariant #1.)
2. **The private key never leaves the TPM.** Generated *in* the TPM; only a CSR 2. **The private key never leaves the TPM.** Generated *in* the TPM; only a CSR
@@ -74,7 +74,7 @@ are deliberately **split** — conflating them is a security regression:
### 1. TPM key + NV cert (exists — `tpm-provision.sh`) ### 1. TPM key + NV cert (exists — `tpm-provision.sh`)
Key generated in the TPM, persisted at `0x81010001`; CSR built with it via the Key generated in the TPM, persisted at `0x81000100`; CSR built with it via the
openssl tpm2 provider (key never leaves the TPM); issued cert written DER to NV openssl tpm2 provider (key never leaves the TPM); issued cert written DER to NV
`0x01800001`. At boot, `dot1x-macsec-up.sh` reads the cert from NV, wpa_supplicant `0x01800001`. At boot, `dot1x-macsec-up.sh` reads the cert from NV, wpa_supplicant
signs EAP-TLS with the handle key, and the hostnqn is derived from the cert serial. signs EAP-TLS with the handle key, and the hostnqn is derived from the cert serial.
@@ -158,7 +158,7 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
``` ```
empty-NV host (early-boot enrol branch) broker (= fleet-mgr bootstrap) admin step-ca empty-NV host (early-boot enrol branch) broker (= fleet-mgr bootstrap) admin step-ca
─────────────────────────────── ───────────────────────────── ───── ─────── ─────────────────────────────── ───────────────────────────── ───── ───────
1. key in TPM @0x81010001 1. key in TPM @0x81000100
2. build CSR (key never leaves) 2. build CSR (key never leaves)
3. POST /enrol/request (CSR+context) ─▶ create PENDING, pick nonce, 3. POST /enrol/request (CSR+context) ─▶ create PENDING, pick nonce,
compute fingerprint compute fingerprint
@@ -295,6 +295,26 @@ point (verify the attestation statement before signing). The CA bootstrap alread
carries a commented `acme-attest` provisioner block carries a commented `acme-attest` provisioner block
(`serving/dot1x-bench/step-ca/bootstrap.sh`). (`serving/dot1x-bench/step-ca/bootstrap.sh`).
### Signing-key handle choice (and the IDevID option)
The fleet signing key is persisted at **`0x81000100`** — a non-reserved **owner**
persistent handle. It is deliberately **not** the EK slot `0x81010001` / `0x81010002`
nor the SRK `0x81000001` / `0x81000002`: firmware and swtpm provisioning routinely
occupy those (bench-confirmed 2026-07-02 — a Proxmox vTPM had the standard **RSA
Endorsement Key** at `0x81010001`, restricted-decrypt, with the canonical EK
authPolicy `837197…`, and its EK cert at NV `0x1c00002`). `EnsureKey` therefore
also refuses to evict any restricted-decrypt object as a safety net. The handle is
overridable via `rd.dot1x.handle=` / `fleet-enrol -handle`.
**Future option — IDevID (`0x81020001`):** if/when the EK/AK attestation path above
lands, the natural home for a TPM-resident *device identity* key is the TCG
**IDevID** handle `0x81020001` (with **LDevID** at `0x81030001`), per *TPM 2.0 Keys
for Device Identity and Attestation*. Moving there would align our key with the
IDevID template + EK-anchored certification flow (the key is certified by an AK that
chains to the EK) rather than a bare owner primary. Not adopted for v1 (it implies
the full IDevID template/cert conventions we don't yet follow); revisit together with
`device-attest-01`.
## Inventory record born at enrolment ## Inventory record born at enrolment
``` ```
@@ -324,7 +344,7 @@ exists. Enrolment is where it comes into being.
approve → cert; pubkey match). Ships in `serving/dot1x-bench/docker-compose.yml`. approve → cert; pubkey match). Ships in `serving/dot1x-bench/docker-compose.yml`.
3. **Host side — BUILT 2026-07-02 (awaiting bench):** the **inline empty-NV 3. **Host side — BUILT 2026-07-02 (awaiting bench):** the **inline empty-NV
trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`) trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`)
talking to `/dev/tpmrm0` directly — ensures the key at `0x81010001`, builds the talking to `/dev/tpmrm0` directly — ensures the key at `0x81000100`, builds the
CSR, POSTs to the broker, shows the Plymouth fingerprint, polls, writes the leaf CSR, POSTs to the broker, shows the Plymouth fingerprint, polls, writes the leaf
cert to NV `0x01800001`. A thin dracut GATE hook (`95dot1x-macsec/tpm-enrol.sh`, cert to NV `0x01800001`. A thin dracut GATE hook (`95dot1x-macsec/tpm-enrol.sh`,
initqueue/settled prio 25) does the empty-NV check + pre-auth DHCP on the initqueue/settled prio 25) does the empty-NV check + pre-auth DHCP on the
+5 -1
View File
@@ -39,6 +39,10 @@ TESTMODE=0
# --- knobs (override on the kernel cmdline) --------------------------------- # --- knobs (override on the kernel cmdline) ---------------------------------
NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800001 NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800001
# Persistent handle for the signing key: a NON-reserved owner handle. NOT the EK
# slot 0x81010001 (which most firmware/swtpm provisioning already occupies) nor
# the SRK 0x81000001 — see EnsureKey / CLAUDE.md invariant #1.
HANDLE=$(getarg rd.dot1x.handle=); [ -n "$HANDLE" ] || HANDLE=0x81000100
ENROL_URL=$(getarg rd.fleet.enrol_url=); [ -n "$ENROL_URL" ] || ENROL_URL=http://boot.fleet.lab/enrol ENROL_URL=$(getarg rd.fleet.enrol_url=); [ -n "$ENROL_URL" ] || ENROL_URL=http://boot.fleet.lab/enrol
NETTMO=$(getarg rd.dot1x.timeout=); [ -n "$NETTMO" ] || NETTMO=60 NETTMO=$(getarg rd.dot1x.timeout=); [ -n "$NETTMO" ] || NETTMO=60
@@ -104,7 +108,7 @@ info "tpm-enrol: pre-auth lease on $NIC; handing off to fleet-enrol ($ENROL_URL)
# It drives the TPM (key/CSR/NV), the broker exchange, and the Plymouth # It drives the TPM (key/CSR/NV), the broker exchange, and the Plymouth
# fingerprint. Exit 0 = enrolled (reboot to converge); 10 = already enrolled # fingerprint. Exit 0 = enrolled (reboot to converge); 10 = already enrolled
# (shouldn't reach here after the NV gate, but treat as normal boot); else fail. # (shouldn't reach here after the NV gate, but treat as normal boot); else fail.
/usr/sbin/fleet-enrol -url "$ENROL_URL" -iface "$NIC" -nvindex "$NVINDEX" /usr/sbin/fleet-enrol -url "$ENROL_URL" -iface "$NIC" -nvindex "$NVINDEX" -handle "$HANDLE"
rc=$? rc=$?
case "$rc" in case "$rc" in
0) 0)
+3 -2
View File
@@ -2,7 +2,8 @@
// //
// Invoked from the dracut hook (95dot1x-macsec/tpm-enrol.sh) ONLY when the // Invoked from the dracut hook (95dot1x-macsec/tpm-enrol.sh) ONLY when the
// secure build boots a host whose TPM NV 0x01800001 is empty. It: // secure build boots a host whose TPM NV 0x01800001 is empty. It:
// 1. ensures an ECDSA signing key exists at TPM handle 0x81010001, // 1. ensures an ECDSA signing key exists at a non-reserved owner handle
// (default 0x81000100 — deliberately NOT the EK slot 0x81010001),
// 2. builds a CSR with it (key never leaves the TPM), // 2. builds a CSR with it (key never leaves the TPM),
// 3. submits CSR + device context to the enrolment broker, shows the returned // 3. submits CSR + device context to the enrolment broker, shows the returned
// fingerprint on the Plymouth splash, and polls until an admin approves, // fingerprint on the Plymouth splash, and polls until an admin approves,
@@ -42,7 +43,7 @@ func main() {
url = flag.String("url", "http://boot.fleet.lab/enrol", "enrolment broker base URL") url = flag.String("url", "http://boot.fleet.lab/enrol", "enrolment broker base URL")
tpmPath = flag.String("tpm", "/dev/tpmrm0", "TPM device path") tpmPath = flag.String("tpm", "/dev/tpmrm0", "TPM device path")
nvHex = flag.String("nvindex", "0x01800001", "TPM NV index for the client cert") nvHex = flag.String("nvindex", "0x01800001", "TPM NV index for the client cert")
handleHex = flag.String("handle", "0x81010001", "TPM persistent handle for the signing key") handleHex = flag.String("handle", "0x81000100", "TPM persistent handle for the signing key (non-reserved owner handle; NOT the EK slot 0x81010001)")
cn = flag.String("cn", "fleet-host", "CSR CommonName (generic; identity is the cert serial)") cn = flag.String("cn", "fleet-host", "CSR CommonName (generic; identity is the cert serial)")
iface = flag.String("iface", "", "network interface for the device-context MAC") iface = flag.String("iface", "", "network interface for the device-context MAC")
caFile = flag.String("cacert", "/etc/dot1x/radius-ca.pem", "CA bundle for an https broker URL") caFile = flag.String("cacert", "/etc/dot1x/radius-ca.pem", "CA bundle for an https broker URL")
+57 -6
View File
@@ -30,7 +30,7 @@ func openSim(t *testing.T) transport.TPM {
func TestEnsureKeyAndSignCSR(t *testing.T) { func TestEnsureKeyAndSignCSR(t *testing.T) {
tpm := openSim(t) tpm := openSim(t)
const h = tpm2.TPMHandle(0x81010001) const h = tpm2.TPMHandle(0x81000100)
signer, created, err := tpmkey.EnsureKey(tpm, h) signer, created, err := tpmkey.EnsureKey(tpm, h)
if err != nil { if err != nil {
@@ -101,14 +101,15 @@ func TestNVWriteAndPresence(t *testing.T) {
} }
// Regression for the bench failure "key at handle is not ECC ... selector value // Regression for the bench failure "key at handle is not ECC ... selector value
// was 1": an older provisioning path left an RSA key at 0x81010001, and EnsureKey // was 1": an older provisioning path left an RSA SIGNING key at the enrol handle,
// fatally failed trying to read it as ECC. It must now evict the stale key and // and EnsureKey fatally failed trying to read it as ECC. It must now evict the
// create a fresh ECC one. // stale key and create a fresh ECC one.
func TestEnsureKeyEvictsStaleNonECC(t *testing.T) { func TestEnsureKeyEvictsStaleNonECC(t *testing.T) {
tpm := openSim(t) tpm := openSim(t)
const h = tpm2.TPMHandle(0x81010001) const h = tpm2.TPMHandle(0x81000100)
// Pre-populate the handle with an RSA key (mimics tpm2-tools/openssl default). // Pre-populate the handle with an RSA SIGNING key (mimics tpm2-tools/openssl
// default) — SignEncrypt, NOT restricted/decrypt, so it is safe to evict.
rsaTmpl := tpm2.TPMTPublic{ rsaTmpl := tpm2.TPMTPublic{
Type: tpm2.TPMAlgRSA, Type: tpm2.TPMAlgRSA,
NameAlg: tpm2.TPMAlgSHA256, NameAlg: tpm2.TPMAlgSHA256,
@@ -163,3 +164,53 @@ func TestEnsureKeyEvictsStaleNonECC(t *testing.T) {
t.Fatalf("post-heal ECDSA signature did not verify") t.Fatalf("post-heal ECDSA signature did not verify")
} }
} }
// Safety guard: if the handle ever holds a restricted-decrypt object (an EK/SRK —
// e.g. handle misconfigured to the EK slot 0x81010001), EnsureKey must REFUSE to
// evict it and error, never clobber the TPM's endorsement/storage identity.
func TestEnsureKeyRefusesToEvictProtectedKey(t *testing.T) {
tpm := openSim(t)
const h = tpm2.TPMHandle(0x81000100)
// A restricted decryption primary — the shape of an EK/SRK (storage key).
skTmpl := tpm2.TPMTPublic{
Type: tpm2.TPMAlgECC,
NameAlg: tpm2.TPMAlgSHA256,
ObjectAttributes: tpm2.TPMAObject{
FixedTPM: true, FixedParent: true, SensitiveDataOrigin: true,
UserWithAuth: true, Restricted: true, Decrypt: true, NoDA: true,
},
Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgECC, &tpm2.TPMSECCParms{
Symmetric: tpm2.TPMTSymDefObject{
Algorithm: tpm2.TPMAlgAES,
KeyBits: tpm2.NewTPMUSymKeyBits(tpm2.TPMAlgAES, tpm2.TPMKeyBits(128)),
Mode: tpm2.NewTPMUSymMode(tpm2.TPMAlgAES, tpm2.TPMAlgCFB),
},
Scheme: tpm2.TPMTECCScheme{Scheme: tpm2.TPMAlgNull},
CurveID: tpm2.TPMECCNistP256,
KDF: tpm2.TPMTKDFScheme{Scheme: tpm2.TPMAlgNull},
}),
}
cp, err := tpm2.CreatePrimary{
PrimaryHandle: tpm2.TPMRHOwner, InPublic: tpm2.New2B(skTmpl),
}.Execute(tpm)
if err != nil {
t.Fatalf("create storage-like primary: %v", err)
}
if _, err := (tpm2.EvictControl{
Auth: tpm2.TPMRHOwner,
ObjectHandle: &tpm2.NamedHandle{Handle: cp.ObjectHandle, Name: cp.Name},
PersistentHandle: h,
}).Execute(tpm); err != nil {
t.Fatalf("persist storage-like key: %v", err)
}
_, _ = (tpm2.FlushContext{FlushHandle: cp.ObjectHandle}).Execute(tpm)
// EnsureKey must refuse (error), and must NOT have evicted the object.
if _, _, err := tpmkey.EnsureKey(tpm, h); err == nil {
t.Fatalf("EnsureKey should refuse to evict a restricted-decrypt (EK/SRK) object")
}
if _, err := (tpm2.ReadPublic{ObjectHandle: h}).Execute(tpm); err != nil {
t.Fatalf("protected object was evicted (should have been left intact): %v", err)
}
}
+34 -10
View File
@@ -1,7 +1,9 @@
// Package tpmkey — TPM 2.0 operations via go-tpm (pure Go, no CGO, no tpm2-tools // Package tpmkey — TPM 2.0 operations via go-tpm (pure Go, no CGO, no tpm2-tools
// / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key // / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key
// at 0x81010001, a crypto.Signer backed by it (for CSR signing), and NV cert // at a non-reserved owner handle (default 0x81000100 — NOT the EK slot
// read-presence + write at 0x01800001. Talks to any transport.TPM, so the same // 0x81010001, see EnsureKey), a crypto.Signer backed by it (for CSR signing),
// and NV cert read-presence + write at 0x01800001. Talks to any transport.TPM,
// so the same
// code runs against /dev/tpmrm0 (production) and the software simulator (tests). // code runs against /dev/tpmrm0 (production) and the software simulator (tests).
// //
// Exported so the ceremony can be tested against the go-tpm simulator from a // Exported so the ceremony can be tested against the go-tpm simulator from a
@@ -105,21 +107,43 @@ func signerFromPub(tpm transport.TPM, handle tpm2.TPMHandle, name tpm2.TPM2BName
return &Signer{tpm: tpm, handle: handle, name: name, pub: pk}, nil return &Signer{tpm: tpm, handle: handle, name: name, pub: pk}, nil
} }
// isProtectedObject reports whether a persistent object is a restricted
// decryption key — i.e. an Endorsement Key or Storage Root Key. We must NEVER
// evict one of those (it would destroy the TPM's endorsement/storage identity,
// including the EK the future EK/AK attestation path depends on). Fail-safe: if
// the public area can't be parsed, treat it as protected.
func isProtectedObject(pub2b tpm2.TPM2BPublic) bool {
pub, err := pub2b.Contents()
if err != nil {
return true
}
a := pub.ObjectAttributes
return a.Restricted && a.Decrypt
}
// ensureKey returns a signer for the persistent key at handle, creating + // ensureKey returns a signer for the persistent key at handle, creating +
// persisting a fresh ECDSA key there if none exists. Returns created=true when // persisting a fresh ECDSA key there if none exists. Returns created=true when
// a new key was generated. // a new key was generated. handle MUST be a non-reserved owner handle (the fleet
// default is 0x81000100) — NOT the EK (0x81010001/2) or SRK (0x81000001/2) slots.
// //
// If the handle already holds an object that is NOT our ECC signing key (e.g. an // If the handle already holds an object that is NOT our ECC signing key (e.g. an
// RSA key left by an older tpm2-tools/openssl provisioning path — go-tpm reports // RSA signing key left by an older tpm2-tools/openssl provisioning path — go-tpm
// "key at handle is not ECC"), it is treated as STALE and evicted, then a fresh // reports "key at handle is not ECC"), it is treated as STALE and evicted, then a
// ECC key is created. Safe on the enrol path: this only runs on an empty-NV host, // fresh ECC key is created. Safe on the enrol path: this only runs on an empty-NV
// so no issued cert references the old key. Only a valid ECC key is reused. // host, so no issued cert references the old key. Only a valid ECC key is reused.
// A restricted-decrypt object (EK/SRK) is NEVER evicted — it errors instead, which
// only happens if handle was misconfigured to a reserved slot.
func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, created bool, err error) { func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, created bool, err error) {
if rp, e := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm); e == nil { if rp, e := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm); e == nil {
if s, e2 := signerFromPub(tpm, handle, rp.Name, rp.OutPublic); e2 == nil { // Guard FIRST: never touch a restricted-decrypt object (EK/SRK). An ECC
return s, false, nil // our ECC key is already there — reuse it // EK/SRK would otherwise parse as a usable ECC key below and be "reused".
if isProtectedObject(rp.OutPublic) {
return nil, false, fmt.Errorf("refusing to evict a restricted-decrypt object (EK/SRK?) at handle %#x — configure a non-reserved owner handle (e.g. 0x81000100)", uint32(handle))
} }
// Stale/wrong-type key occupying the handle — evict so we can recreate. if s, e2 := signerFromPub(tpm, handle, rp.Name, rp.OutPublic); e2 == nil {
return s, false, nil // our ECC signing key is already there — reuse it
}
// Stale/wrong-type signing key occupying the handle — evict so we recreate.
if _, e2 := (tpm2.EvictControl{ if _, e2 := (tpm2.EvictControl{
Auth: tpm2.TPMRHOwner, Auth: tpm2.TPMRHOwner,
ObjectHandle: &tpm2.NamedHandle{Handle: handle, Name: rp.Name}, ObjectHandle: &tpm2.NamedHandle{Handle: handle, Name: rp.Name},
+4 -1
View File
@@ -14,8 +14,11 @@ network={
client_cert="/run/dot1x/client.pem" client_cert="/run/dot1x/client.pem"
# BENCH-VERIFY: TPM key reference method on your wpa_supplicant build. # BENCH-VERIFY: TPM key reference method on your wpa_supplicant build.
# Handle is the fleet signing-key handle (non-reserved owner handle; NOT the
# EK slot 0x81010001 — see CLAUDE.md invariant #1). Keep in sync with the
# enrolment -handle / rd.dot1x.handle default.
# Option A (OpenSSL tpm2 provider, direct persistent handle): # Option A (OpenSSL tpm2 provider, direct persistent handle):
private_key="handle:0x81010001" private_key="handle:0x81000100"
# Option B (tpm2-pkcs11 with a store regenerated into /run at boot): # Option B (tpm2-pkcs11 with a store regenerated into /run at boot):
# private_key="pkcs11:token=fleet;object=dot1x-key;type=private;pin-value=..." # private_key="pkcs11:token=fleet;object=dot1x-key;type=private;pin-value=..."
+1 -1
View File
@@ -223,7 +223,7 @@ printf '%s' \
"$STIG_FRAGMENT" \ "$STIG_FRAGMENT" \
"$DOT1X_FRAGMENT" \ "$DOT1X_FRAGMENT" \
"rd.neednet=1 systemd.volatile=overlay " \ "rd.neednet=1 systemd.volatile=overlay " \
"rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \ "rd.dot1x.nvindex=0x01800001 rd.dot1x.handle=0x81000100 rd.dot1x.timeout=60 " \
"rd.fleet.enrol_url=${ENROL_URL} " \ "rd.fleet.enrol_url=${ENROL_URL} " \
"rd.nvmf.discover=tcp,${NVMET_TRADDR},4420 " \ "rd.nvmf.discover=tcp,${NVMET_TRADDR},4420 " \
"root=/dev/mapper/root rootfstype=erofs rootflags=ro " \ "root=/dev/mapper/root rootfstype=erofs rootflags=ro " \
+1 -1
View File
@@ -83,7 +83,7 @@ qm set <vmid-A> --tpmstate0 <storage>:1,version=v2.0
3. **Enrol each workstation's TPM** (one-shot, on a provisioning network that can 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 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 at `0x81000100`, builds a CSR with it, has step-ca sign it, writes the cert to
NV `0x01800001`. Each host gets a unique cert → unique hostnqn. NV `0x01800001`. Each host gets a unique cert → unique hostnqn.
4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec 4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec
+3 -2
View File
@@ -3,7 +3,8 @@
# #
# One-shot enrolment for ONE workstation. Models the real flow (invariant #1: # One-shot enrolment for ONE workstation. Models the real flow (invariant #1:
# per-host identity is created at enrolment, never baked into the image): # 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 # 1. generate a signing key INSIDE the TPM, persist it at handle 0x81000100
# (non-reserved owner handle; NOT the EK slot 0x81010001)
# 2. build a CSR using that TPM key (private key never leaves the TPM) # 2. build a CSR using that TPM key (private key never leaves the TPM)
# 3. have step-ca sign it (token-gated 'enrol' provisioner) # 3. have step-ca sign it (token-gated 'enrol' provisioner)
# 4. write the issued cert (DER) to TPM NV index 0x01800001 # 4. write the issued cert (DER) to TPM NV index 0x01800001
@@ -25,7 +26,7 @@
set -euo pipefail set -euo pipefail
HOSTID=${1:?usage: tpm-provision.sh <host-id> e.g. ws-vm-a} HOSTID=${1:?usage: tpm-provision.sh <host-id> e.g. ws-vm-a}
HANDLE=${HANDLE:-0x81010001} HANDLE=${HANDLE:-0x81000100}
NVINDEX=${NVINDEX:-0x01800001} NVINDEX=${NVINDEX:-0x01800001}
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443} CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443}
+2 -2
View File
@@ -4,7 +4,7 @@
# #
# Run as root on a booted workstation (the open-port test image is fine — it # 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 # 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 # touches the real 0x81000100 / 0x01800001. Pass --use-real-handles to exercise
# the actual locations instead (still cleaned up unless --keep). # the actual locations instead (still cleaned up unless --keep).
# #
# It validates, in order: # It validates, in order:
@@ -24,7 +24,7 @@ for a in "$@"; do case "$a" in
esac; done esac; done
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
if [ "$USE_REAL" = 1 ]; then HANDLE=0x81010001; NV=0x01800001 if [ "$USE_REAL" = 1 ]; then HANDLE=0x81000100; NV=0x01800001
else HANDLE=0x81008000; NV=0x01500000; fi else HANDLE=0x81008000; NV=0x01500000; fi
WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0 WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0