Development documentation

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

Manual reference: host gates

This is the manual equivalent of the developer bring-up script: the host-side gates an operator provisions before the agent will start. Attestation CA enrollment and sealed keys are covered in their own documents (ca-enrollment, sealed-keys).

1. Operator key + signed BPF object

sudo install -d -m 0700 /etc/lota
sudo /usr/bin/lota-agent --gen-signing-key /etc/lota/policy
sudo chmod 0600 /etc/lota/policy.key
sudo chmod 0644 /etc/lota/policy.pub
sudo /usr/bin/lota-agent --sign-policy /usr/lib/lota/lota_lsm.bpf.o \
    --signing-key /etc/lota/policy.key

A key at /etc/lota/policy.pub is used without being named: the agent prefers it over the key the package shipped, and no package owns that path, so it survives every upgrade. policy_pubkey in /etc/lota/lota.conf (copy configs/lota.conf.example and edit) is only needed to name a key somewhere else. The agent reads that file by default; pass --config /path if the operator policy lives elsewhere.

The make sign-bpf SIGNING_KEY=/etc/lota/policy.key target wires the sign call into the build system for CI / packaging.

Note

The agent package ships lota_lsm.bpf.o unsigned on purpose, so each operator signs it with their own key. Upgrading the package replaces the object and invalidates the previous signature -- re-run the --sign-policy step above after every lota-agent package upgrade, then re-run lota-install to confirm the operator-trust stage. lota-install only verifies the signature; it never signs on the host (a locally generated key would let local malware re-sign a tampered object).

2. Kernel-enforced immutability of the agent binary

The agent refuses to start unless the kernel will reject a modified read of /usr/bin/lota-agent, so a binary swapped while the host is offline is caught at the next boot. Two mechanisms satisfy this, and the agent accepts either (agent_self_immutability_enforced()):

fs-verity -- ext4, btrfs, f2fs and recent XFS:

# ext4 needs the feature enabled at mkfs time
# or via `sudo tune2fs -O verity /dev/sdX` on an unmounted device.
# btrfs / f2fs ship verity in 5.15+.
sudo fsverity enable /usr/bin/lota-agent
sudo fsverity measure /usr/bin/lota-agent

Verity is enabled once per inode and cannot be enabled twice, so on a binary that already carries it enable exits non-zero with FS_IOC_ENABLE_VERITY failed on '/usr/bin/lota-agent': File exists. That is the armed state, not a failure; measure printing a digest is the check. A re-install replaces the inode and the bit with it, which is why bring-up runs enable on every pass.

If fsverity enable returns EOPNOTSUPP, the filesystem has no verity support. Production lays this down at install time via dracut + an fs-verity-enabled rootfs.

The hash algorithm is the operator's choice: LOTA reads whichever digest the kernel reports, SHA-256 (the fsverity enable default, and what the RPM fs-verity plugin and composefs produce) or SHA-512 (--hash-alg=sha512). An allowlist entry matches only under the algorithm it was captured with, so re-capture the digest after changing it -- and note that the algorithm is fixed when verity is enabled, so changing it on an existing file means writing a fresh copy of that file.

Signed IMA xattr -- any filesystem with a security xattr namespace, including XFS and ZFS where fs-verity is unavailable. Under ima_appraise=enforce (see section 3) the kernel refuses to exec or read a file whose content does not match the hash signed in its security.ima xattr by a key on the .ima keyring, which is the same offline-swap guarantee:

# sign the binary; load the matching cert onto the .ima keyring
sudo evmctl ima_sign --key /etc/keys/ima.key /usr/bin/lota-agent
getfattr -m security.ima -d /usr/bin/lota-agent   # verify present

Bare (unsigned) digest does not count -- attacker who swaps the binary offline can recompute it. The agent and the installer accept only a signature-type security.ima xattr.

3. IMA appraisal policy

The agent parses /proc/cmdline and refuses to start unless ima_appraise=enforce (block on integrity failure) or ima_appraise=fix (block on signature failure, write missing xattrs) is present. ima_appraise=log and the default off are non-blocking and do not satisfy the kernel-floor. The check does not read /sys/kernel/security/ima/policy because that file is write-only on kernels built without CONFIG_IMA_READ_POLICY (Fedora 44's default).

sudo grubby --update-kernel=ALL --args="ima=on ima_appraise=enforce"
sudo reboot

The cmdline only sets the appraisal mode; the kernel still needs a loaded IMA policy with appraise rules for anything to be checked. The appraisal content -- the signatures on disk and the rule set -- is distribution or operator-supplied. LOTA ships neither an xattr-signing pipeline nor a production appraisal policy, and the kernel-floor check in the agent pins only the mode. On a verity-capable rootfs LOTA's own binary is integrity-bound through fs-verity and the PCR14 boot commitment independently of IMA appraisal; on a filesystem without verity (XFS, ZFS) the signed security.ima route from section 2 is what binds the agent binary instead. Two supported routes for the appraisal content:

  1. Distribution signatures. On Fedora/RHEL, packages can carry IMA file signatures applied at install time (rpm-plugin-ima, with the distribution's IMA certificate loaded onto the .ima keyring) and the built-in ima_policy=appraise_tcb cmdline policy appraises the TCB ranges against them. Verify the signatures actually exist (getfattr -m security.ima -d /usr/bin/lota-agent) before enabling enforce.

  2. Operator image pipeline. Deployments that build their own images sign executables at image-build time (evmctl ima_sign --key <operator key> over the executable closure) and load the matching certificate onto the .ima keyring (on a Secure Boot host, via a MOK-enrolled certificate). The policy and key lifecycle are owned by the image pipeline, not by LOTA.

Sequencing either route: ima_appraise=enforce with appraise rules loaded blocks every execution the rules match that lacks a valid signature -- on a rootfs without signatures that bricks the host at the next boot. Stage with ima_appraise=fix for one boot (the kernel writes missing xattrs as it walks matched files) before switching to enforce.

The shipped configs/ima/lota-ima-policy is the developer baseline that scripts/lota-dev-bringup.sh loads on a dev host running ima_appraise=log: it measures every exec/mmap for the IMA log and its appraise_type=imasig rules verify signatures where they exist. IMA rules cannot match by path, so it is not a "LOTA-only" policy -- do not load it under enforce on a host without signatures.

4. SELinux label on /dev/tpm

The udev rule from configs/udev/99-lota-tpm.rules lays this down on device-add. After make install:

sudo udevadm control --reload-rules
sudo udevadm trigger /dev/tpmrm0 /dev/tpm0
ls -lZ /dev/tpm0 /dev/tpmrm0                # expect lota_tpm_device_t

The type is the check: lota_tpm_device_t is what the agent's gate looks for. The SELinux user in front of it follows from how the file arrived -- system_u on a device the policy labelled, unconfined_u on a file an unconfined session installed -- and neither the gate nor restorecon treats it as wrong.

5. AIK + PCR14 reset

The initramfs helper first pins PCR14 with the LOTA lock, then the agent binds PCR14 against its own self_hash once per boot. The TPM's clock counters take no part in either digest: a TPM obfuscates them per signing key, so a commitment that folded them in could only ever be rederived by one publisher's AIK, and they are in every token the agent hands out anyway.

PCR14 is not pristine on every platform. On UEFI Secure Boot, shim measures the MOK state (MokList, SbatLevel, MokListRT) into PCR14 before the initramfs runs, so the register is already non-zero when the lock helper extends it. The helper therefore folds whatever PCR14 holds -- the baseline -- into the chain instead of requiring 0^32, and records it at /run/lota/pcr14_baseline (raw 32 bytes, on the /run tmpfs that survives the initramfs handoff). The agent reads that file so its derivations anchor on the same baseline, and the verifier reconstructs the baseline independently by replaying the signed TPM event log -- the LOTA extends happen after ExitBootServices and never enter that log, so the log's PCR14 events are exactly the firmware/shim baseline. A UEFI host that boots without shim measures nothing into PCR14, so its baseline is legitimately 0^32 and the chain anchors on zero. Because the verifier derives the baseline from the signed log, a forged /run handoff cannot move trust: it only makes the host fail closed.

Both halves of that chain are mandatory, and both refuse a non-UEFI host before touching the TPM: the initramfs helper exits non-zero (which aborts the boot transition, its unit is ordered before initrd-root-fs.target) and the agent returns ENOTSUP from its self-measurement, each naming the absent /sys/firmware/efi. Legacy BIOS/CSM is unsupported -- see Platform support. When the lock did not run this boot the agent says so and names the fix: install the 90lota dracut module, rebuild the initramfs (dracut -f --add lota for the booted kernel, or dracut -f --regenerate-all for every installed one) and cold reboot. It recognises that case by the absence of the handoff file above, not by the register's value -- on a shim host PCR14 holds the MOK measurement whether or not the lock ran, so the value alone cannot tell a machine that needs its initramfs rebuilt from one that was tampered with. The agent refuses to extend a commitment onto an unlocked register either way, because the verifier validates only the lock-then-commit chain.

The lock belongs in every installed kernel's initramfs, not only the running one. The kernel command-line floor is armed on every boot entry, so a kernel that can be selected from the boot menu without the lock boots a host with no commitment; lota-install regenerates them all and reports the stage unsatisfied while any installed kernel is missing the helper. A kernel installed later picks the helper up from the packaged dracut module without further action.

A MOK change (enrolling a key with mokutil, a shim/SBAT update) shifts the baseline and therefore the final PCR14, so an enrolled host reports an INTEGRITY_MISMATCH against its pinned baseline until an operator re-anchors it -- the same review gate a firmware change goes through, since enrolling a MOK key is a security-relevant boot-chain change. The agent therefore provisions its AIK before self_measure() runs; attestations issued before --enroll fall back to the unauthenticated clock and must be rebound on the next start. A re-install that changes either the initramfs helper or the agent binary without rebuilding initramfs and cold-rebooting reports a PCR14 derivation mismatch or This is not the lota-agent build PCR 14 committed to when the host booted. Wipe the witness file and the persistent AIK, then reboot. Stop every unit that runs an agent, not only the daemon: lota-attest.service runs lota-agent --attest and keeps the TPM clock witness current, so a wipe that leaves it running deletes a file the next attestation round rewrites seconds later.

sudo systemctl stop lota-agent.service lota-agent.socket \
    lota-attest.service
sudo find /var/lib/lota -mindepth 1 -maxdepth 1 \
    \( -name 'aik*' -o -name 'clock*' -o -name 'boot_commit*' \
       -o -name 'snapshot*' \) -delete
# host that enrolled with publisher keeps that publisher's AIK in its profile;
# wiping the key means wiping the enrollment with it
sudo rm -rf /var/lib/lota/profiles
for h in 0x81010002 0x81010003 0x81010004 0x81010005 \
         0x81010010 0x81010011 0x81010012 0x81010013 \
         0x81010014 0x81010015 0x81010016 0x81010017; do
    sudo tpm2_evictcontrol -C o -c "$h" 2>/dev/null || true
done
sudo reboot

After reboot the agent's first start provisions a fresh AIK and extends PCR14 cleanly. Subsequent starts that follow a clean shutdown reuse the witness so the gate is silent.