Development documentation

This is the in-progress lota-next branch. For the released version, see the stable documentation.

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.