Attestation CA enrollment
The verifier authenticates an agent only through a certificate issued by the
attestation CA (lota-attest-ca), so each host enrolls once before it can
attest. Enrollment runs the TPM 2.0 credential-activation ceremony: the CA
verifies the EK certificate chains to a trusted manufacturer root, proves the
AIK and that EK share one TPM, and issues a short-lived AIK certificate whose
subject is the device pseudonym. The EK is presented only to the CA; verifiers
never see it.
Stand up the CA with your CA key, the trusted manufacturer EK roots and a server
TLS keypair (examples/enrollment/gen-ca.sh generates the CA material).
Production fleets span several TPM manufacturers, so trust a pin-enforced
multi-vendor bundle with -ek-root-bundle; the CA fails closed if any pinned
root is missing, mismatched, or unpinned (see configs/ek-roots/README.rst
for how to materialize one):
lota-attest-ca -listen :8444 \
-ca-cert ca.crt -ca-key ca.key \
-tls-cert tls.crt -tls-key tls.key \
-pseudonym-key pseudonym.key \
-ek-root-bundle /var/lib/lota/ek-roots
-ek-root <file> is still accepted and adds operator-supplied roots (for
example a swtpm CA in the enrollment demo) on top of the bundle; pass either or
both.
Pair the bundle with the manufacturers' EK revocation feeds: -ek-crl <file>
(repeatable) loads a manufacturer CRL, enrollment rejects a revoked EK, and
SIGHUP reloads a rewritten feed in place. See the "Manufacturer CRLs" section of
configs/ek-roots/README.rst.
CA signing key
The CA signing key is the anchor every issued AIK certificate chains to, so the
CA loads it as a crypto.Signer and checks the signer's public key against
the CA certificate at startup -- a key that does not match the certificate is
refused, whether it comes from a file or an external store.
The source is selectable: an on-disk PKCS#8 PEM (-ca-key) for development,
or an external key store that never exposes the private key for production. The
sections below cover the production options.
-ca-key is a development-only fallback. The key sits in the clear on the
host, so a host compromise yields the fleet's signing root. The CA logs a loud
warning at startup whenever it is used, and it is never the production default:
a production CA holds the key in an HSM (next section) and keeps -ca-key for
local bring-up and tests only.
CA signing key in an HSM (PKCS#11)
A production CA holds its signing key in a PKCS#11 token (an HSM, or SoftHSM for tests) so the private key never leaves the device. This needs a PKCS#11-enabled build of the CA -- the default binary is pure-Go and has no PKCS#11 support:
make BUILD_DIR=/var/tmp/lota-build attest-ca GO_TAGS=pkcs11 # cgo + PKCS#11
Point the CA at the token instead of -ca-key. The PIN is read from the
environment, never a flag, so it stays out of the process argument list:
export LOTA_CA_PKCS11_PIN=... # token user PIN
lota-attest-ca -listen :8444 \
-ca-cert ca.crt \
-ca-key-pkcs11-module /usr/lib64/softhsm/libsofthsm2.so \
-ca-key-pkcs11-token lota-ca \
-ca-key-pkcs11-label lota-ca-key \
-tls-cert tls.crt -tls-key tls.key \
-pseudonym-key pseudonym.key \
-ek-root-bundle /var/lib/lota/ek-roots
Select the key by -ca-key-pkcs11-label (CKA_LABEL) or -ca-key-pkcs11-id
(CKA_ID, hex). The CA checks the token key's public key against ca.crt at
startup and refuses to run on a mismatch, the same check applied to an on-disk
key, so a wrong token or label cannot sign under the CA identity.
-ca-key and the -ca-key-pkcs11-* flags are mutually exclusive, and that
is answered on any of the four: passing -ca-key beside a token, label or
id is refused just as it is beside the module, and the error names the flags it
found. A selector given without -ca-key-pkcs11-module is refused by name as
well, because nothing has been told which module to open -- the CA never falls
back to an on-disk key when a token was asked for, since that failure would
hand the fleet's trust root to a file the operator believed they had
overridden.
The CA key ceremony, rotation, and the offline-root / online-intermediate topology are covered in ../../security/ca-key.
Materializing the EK root bundle
The bundle ships empty: the supported set is every TPM whose EK certificate chains to a root you can verify and pin, not a fixed vendor list. Build it from the platforms you actually attest -- draft a sources line from a host's EK certificate, verify each pin out of band against the vendor's published value, then materialize the bundle:
# walk the EK cert's issuer chain to the self-signed root and draft a line
sudo tpm2_nvread 0x01c00002 -o ek.der
cp configs/ek-roots/sources.example sources
scripts/lota-ek-root-pin.sh ek.der >>sources
# after verifying each pin out of band, fetch and pin the roots
scripts/lota-ek-roots-update.sh sources /var/lib/lota/ek-roots
lota-ek-root-pin.sh prints a fingerprint over what the network returned; it
does not vouch for it, so confirm the pin against the vendor before the line
enters sources.
The bundle does not have to cover the whole path. A TPM that stores manufacturer intermediates of its own presents them with the enrollment request, and the CA uses them to complete a route to a pinned root -- never to anchor one. What the bundle must carry is every certificate on the path the device does not: on Intel PTT that is the root and the two OnDie intermediates below it, with the three nearest the leaf coming off the chip.
See configs/ek-roots/README.rst for the full flow.
Enrolling a host
Enroll the agent once per host (the daemon then renews the certificate on its own before the TTL, default 24h, expires):
sudo lota-agent --enroll --ca-server ca.example --ca-port 8444 \
--ca-cert ca.crt
# stores the issued certificate and the CA endpoint in the publisher
# profile the trust anchor names, under /var/lib/lota/profiles/
--ca-cert is required, and it must be the CA anchor -- the certificate
whose key signs AIK certificates -- not the listener certificate the CA serves
TLS with. Beyond verifying the TLS peer, the trust anchor is what names the
publisher: the profile directory is the SHA-256 of the anchor's
SubjectPublicKeyInfo, so a host answering to several publishers keeps one AIK,
one certificate and one enrollment record per publisher and no publisher can
correlate the host through a shared identity. The endpoint is deliberately not
the identity -- an address is mutable and two publishers can share a hostname,
while reissuing the CA certificate over the same key keeps the profile.
A CA that refuses the enrollment says why in the agent's own output. Each refusal prints the status the CA sent and the sentence that goes with it -- an endorsement key that is not accepted here, an attestation key template that is not, a challenge the CA no longer holds, a CA that is at its concurrency limit, a token that was not recognised -- along with whether the answer is worth retrying. Two of them are worth separating: an internal error is a fault at the CA and a later attempt may succeed, while a rejected endorsement key is a decision about this machine that will be made the same way every time. A status the agent has no name for is printed as a number and said to be one, which is what an older agent meeting a newer CA looks like.
The agent says so when it can tell. Both --enroll and --add-publisher
warn when the file named is not a CA certificate, naming what a re-key of it
would cost; an anchor produces no such line. It is a warning rather than a
refusal, because a self-signed leaf is a legitimate pin for a deployment that
wants one and nothing in the file says which was meant.
Naming the listener certificate here instead is the mistake with the quietest consequences. It is the one certificate in a deployment that is rotated on a schedule, and a rotation that generates a new key changes the SPKI: every host of that publisher becomes a new device at once. Each one enrolls again, provisions another AIK in another TPM persistent slot, and needs consent again, while its previous enrollment record and certificate sit in a profile directory nothing reads. Nothing reports that this has happened -- the hosts look new, because to that publisher they are. Issue the listener from the CA and pass the CA, which is what examples/enrollment/gen-ca.sh generates.
The AIK is part of the profile, not shared across it. Each publisher's
enrollment provisions its own key, in its own TPM persistent slot, with its own
rotation metadata and userAuth -- a shared key would be a stable handle two
publishers could correlate the same machine through. The slot is taken from a
bounded range (0x81010010 upwards, sized to the profile limit), recorded in
the profile so a config edit cannot shift a publisher onto another one's key,
and a slot already holding an object the host did not record is skipped rather
than evicted. A host that runs out of the range refuses the enrollment instead
of reusing a key; persistent slots are shared with everything else on the
machine, so raising the ceiling is a deliberate act, not an automatic one.
The first enrollment records the CA endpoint in that profile, so the running
agent renews the certificate on its own against that endpoint as it nears
expiry (it re-enrolls once the cert enters its final third of validity, backing
off when the CA is unreachable). The renewal is automatic whenever an endpoint
is on disk, so an enrolled host needs no scheduled --reenroll. The same
guided command stays available as a manual override -- before the certificate
TTL expires, or after the agent rotates the AIK -- naming only the anchor that
selects the profile:
sudo lota-agent --reenroll --ca-cert ca.crt
It is also the command that moves a host onto per-publisher keys. An AIK created before the publisher's identity entered the TPM creation template is one key shared by every publisher enrolled on that host, and a TPM key cannot be rewritten in place, so the agent refuses such a record instead of carrying it forward and says which publisher it belongs to. Re-enrolling that profile creates its own key; the device pseudonym is derived from the key and moves with it, so the publisher sees a device it has not met before and its verifier re-establishes the baseline.
Point every verifier at the CA root:
lota-verifier -aik-ca-cert ca.crt ...
A host that has not enrolled (no AIK certificate) is refused under the
production --require-cert default.
See examples/enrollment/README.rst for the full end-to-end walk-through.
Self-service re-anchor (diverse-fleet profile)
On the diverse-fleet profile (a policy with require_secureboot), a
legitimate firmware update shifts PCR 0/1 and would otherwise reject the host
until an operator clears its baseline. profile: consumer in the policy lets
the verifier re-pin the per-device baseline itself when the drift preserves the
Secure Boot root of trust (PK/KEK/db unchanged, dbx append-only, Secure Boot
still on, firmware version not rolled back); a hardware platform that reports no
firmware version (ESRT, common on DIY boards flashed with the vendor tool
rather than a UEFI capsule) takes a Low-Firmware-Assurance path. The LFA
re-anchor also applies automatically -- the player is never blocked waiting on
the operator -- but it flags the device for post-fact review: list flagged
devices with GET /api/v1/reanchor/review and clear one after inspecting it
with POST /api/v1/clients/{clientID}/reanchor-review-ack (each LFA re-anchor
is also logged at security level and counted in the lfa re-anchor metric).
If a reviewed re-anchor looks wrong, revoke or ban the device through the
existing endpoints.
The profile lives in the policy because it describes the fleet, not the
deployment, and one verifier can serve several tenants and therefore several
fleets. enterprise (and an unset profile) leaves the path off, which is
right where firmware drift is a finding and re-baselining stays a deliberate
operator action. --enable-self-service-reanchor remains as an override in
both directions for an operator who has to contradict a policy they cannot
immediately re-sign; left unset, each policy decides for itself.
Operator-forced re-anchor and client removal
Deliberate counterpart of the self-service path works on every profile and needs no verifier flag. When a platform change is legitimate but the self-service re-anchor refuses it (or the profile does not enable it), drop the device's pinned baselines through the admin API; the next attestation re-establishes trust per the active TOFU/policy configuration:
curl -X POST https://verifier:8080/api/v1/clients/{clientID}/reanchor \
-H "Authorization: Bearer $ADMIN_KEY" \
-d '{"actor":"ops@example.com","note":"planned firmware update"}'
actor is required and lands in the audit log together with the note;
the action is also logged at security level and counted in the forced
re-anchor metric. The enrollment is untouched -- the host keeps attesting
with its enrolled AIK identity.
To remove a device from the fleet entirely (decommissioned host, or trust state that must be rebuilt from scratch), delete the client. This drops the baselines and any AIK-store registration; the optional body is audit metadata:
curl -X DELETE https://verifier:8080/api/v1/clients/{clientID} \
-H "Authorization: Bearer $ADMIN_KEY" \
-d '{"actor":"ops@example.com","note":"decommissioned"}'
Revocations and hardware bans are keyed separately and survive the delete, so removing a client cannot be used to shed either -- a revoked identity that re-enrolls is still revoked.
When a publisher's key stops matching its certificate
A publisher profile holds three things that have to agree: the persistent handle its attestation key occupies, the authorization stored beside it, and the certificate the CA issued over that key. A TPM clear replaces the key, and so does any verb pointed at a handle a profile already owns.
When they disagree, the TPM refuses to sign and each refusal spends a
dictionary-attack attempt. The counter drains slowly -- one attempt every two
hours on a typical firmware TPM, against a ceiling of 32 -- so a host that
retried a wrong credential could walk itself into a lockout that needs a
lockoutAuth the machine does not hold.
The agent therefore compares the key at the handle against the certificate before it quotes, which costs no authorization and no attempt, and refuses the round when they differ:
ERR: The attestation key at this publisher's handle is not the key their
certificate was issued over, so every quote would be refused by the
TPM and each refusal spends a dictionary-attack attempt. No round is
attempted until they issue a certificate for the key that is here
now: lota-agent --reenroll --ca-cert <their anchor>. Publisher: ...
--list-publishers reports the same state, since it is the surface to check
first -- everything else it prints survives the key being replaced:
1feba674...
attestation key at TPM handle 0x81010010
certificate 39566 s of validity left
BROKEN the key at that handle is not the key this certificate
was issued over, so every quote is refused. Re-enroll
this publisher: lota-agent --reenroll --ca-cert <anchor>
The remedy is the one both messages name: re-enroll that publisher, which issues a certificate for the key the TPM holds now. The agent picks the change up on its next round, with no restart.
Both this refusal and a TPM authorization failure are said once and do not shorten the interval. A credential that is wrong is wrong on the next attempt too, so a backoff would only raise the rate at which nothing can happen -- and where the TPM is the one refusing, raise the rate at which the host spends attempts. The target keeps its ordinary cadence, the publisher stays listed as not attested, and the agent says so again only when the state changes: when the key and the certificate agree once more, or when a round succeeds. A configuration reload does not re-announce either, because the configuration file is not what changed.
Other publishers are unaffected. Each has its own key, schedule and failure state, so one publisher standing still does not slow, stop or silence the rest -- which the log shows by continuing to report their enrollment, rotation and renewal work as usual.
AIK rotation status over D-Bus
The agent rotates the AIK on its own schedule (--aik-ttl, default 30d). It
surfaces the rotation state over D-Bus so an operator -- or a fleet monitor --
can see when a rotation is due and when a re-enrollment is needed.
GetRotationStatus returns the generation, the AIK creation time, the
next-rotation deadline, any open grace window, and whether the stored
certificate has been outdated by a rotation:
busctl call org.lota.Agent1 /org/lota/Agent1 org.lota.Agent1 \
GetRotationStatus
# (tttttb) generation provisioned_at rotation_deadline ... reenroll_required
busctl get-property org.lota.Agent1 /org/lota/Agent1 org.lota.Agent1 \
ReenrollRequired
When ReenrollRequired is true, the host rotated its AIK and the issued
certificate is stale; clear it with the guided sudo lota-agent --reenroll
above. The same properties emit PropertiesChanged, so a subscriber is
notified the moment a rotation happens rather than having to poll.
The schedule has a floor of 3600 seconds, and it applies wherever the value is
set: --aik-ttl and the aik_ttl key in lota.conf both meet it. Below
an hour a host rotates its attestation key faster than an enrolment completes,
so every rotation invalidates the certificate that names the old key and the
host spends its time on TPM key creation and CA round trips -- on a firmware
TPM, the slow operations. A smaller value is lifted to the floor, and the agent
says so on stderr; when it came from the file, the warning names the file and
the line. aik_ttl = 0 is not a cadence -- it selects the built-in default.
Assigning tenants at enrollment
A multi-tenant deployment gives each enrolled device a tenant, which the
verifier then uses to scope that device's bans, revocations, baselines, logs,
and PCR policy. The tenant is assigned by the CA at enrollment and written into
the issued AIK certificate as a single OrganizationalUnit; the verifier
reads it only after verifying the certificate chain, so a host cannot choose
its own tenant.
The enterprise path resolves the tenant from an EK-to-tenant manifest. The
manifest is a text file, one entry per line, mapping an endorsement-key
fingerprint to a tenant name; blank lines and lines beginning with # are
ignored:
# <ek_sha256> <tenant>
3b1f...c7 acme
9a20...4e beta
The fingerprint is the SHA-256 of the endorsement key's public modulus. For an
EK certificate in ek.pem an operator computes it with:
openssl x509 -in ek.pem -noout -modulus \
| sed 's/^Modulus=//' | xxd -r -p | sha256sum
Point the CA at the manifest with --tenant-manifest:
lota-attest-ca ... --tenant-manifest /etc/lota/tenants.txt
An endorsement key with no manifest entry lands in the reserved default
tenant, which is encoded as the absence of an OrganizationalUnit so
single-tenant deployments keep issuing byte-identical subjects. Add
--tenant-manifest-strict to refuse enrollment for any endorsement key
absent from the manifest; the manifest then doubles as an EK allowlist. A
strict rejection happens before the credential challenge is wrapped, so a
barred device never consumes an enrollment session.
A barred device is told its endorsement key was rejected, as is a key that does not chain to a pinned root, one its manufacturer's CRL revokes and one whose modulus carries the ROCA fingerprint. Each is a verdict on the key that a retry does not change; the CA log names which one applied. An internal-error status means the CA could not serve the request and a later attempt may succeed, as with an unverifiable or expired CRL feed.
The device pseudonym in the certificate CommonName mixes in a named
tenant, so one TPM enrolling into two tenants yields two distinct device IDs
and never collides in the verifier's per-tenant state. A device that moves to
a new tenant is therefore a new identity there and re-enrolls from scratch.
The default tenant keeps the pseudonym derivation used before tenant
assignment, so devices enrolled by an older CA re-enroll under the same
device ID and keep their verifier-side state.
The verifier reads this tenant from the certificate and scopes the device's bans, revocations, PCR policy, and operator-API visibility to it. See ../multi-tenancy for how tenancy scopes verifier state and how to configure scoped monitoring-API access.
Assigning tenants with enrollment tokens
An EK manifest presumes the operator knows every endorsement key up front, which a consumer deployment cannot: a game title enrolls machines the operator has never seen. Those deployments hand each tenant's install flow a shared enrollment token, an opaque secret string the agent presents with its begin request; the CA resolves the tenant from the token instead of the EK.
The CA never stores tokens, only their digests. Mint a token per tenant, hand it to that tenant's distribution channel, and record its SHA-256 in a token-to-tenant file with the same shape as the manifest:
printf %s "$TOKEN" | sha256sum
# <token_sha256> <tenant>
5e88...12 game-alpha
a71c...09 game-beta
Point the CA at the file with --enrollment-tokens:
lota-attest-ca ... --enrollment-tokens /etc/lota/enroll-tokens.txt
A presented token is an explicit tenant assignment and fails closed: a token
matching no entry (or any token on a CA without --enrollment-tokens) is
refused with a token rejection before the credential challenge is wrapped, and
never falls back to the EK manifest or the default tenant. Only a token-less
enrollment takes the EK-manifest path, so both mechanisms can serve one CA.
Add --require-enrollment-token to refuse token-less enrollments outright;
the token set is then the sole admission control, which is the consumer shape
where no enrolling EK is known in advance.
On the device, store the token in a root-only file (never on a command line,
where it would be visible in ps and shell history) and point --enroll
at it:
sudo install -m 600 /dev/null /etc/lota/enroll.token
printf %s "$TOKEN" | sudo tee /etc/lota/enroll.token >/dev/null
sudo lota-agent --enroll --ca-server ca.example --ca-port 8444 \
--ca-cert /etc/lota/ca.crt \
--enroll-token-file /etc/lota/enroll.token
The token must be 1 to 128 printable, non-whitespace ASCII characters; a
trailing newline in the file is tolerated. A successful enrollment persists
the token (not the file path) in the profile's root-only enrollment record
next to the CA endpoint, so --reenroll and the daemon's automatic certificate
renewal keep presenting it without further operator input. Re-run
--enroll with a new token file to replace it.
Rotate a token by minting a new one, adding its digest to the file alongside the old entry (both then enroll into the tenant), shipping the new token to the install flow, and deleting the old digest once the rollout completes. Removing a digest stops future enrollments with that token but does not revoke certificates it already produced; revoke those at the verifier.
Wire compatibility: an agent that presents no token keeps speaking protocol version 1, so upgraded agents interoperate with an old CA, and the CA answers each request in the version it arrived with, so old agents interoperate with an upgraded CA. The token travels only inside the enrollment TLS channel.