Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/content/docs/security/nitrotpm.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
title: NitroTPM and runner authorization
description: How OpenTofu, NitroTPM, AWS STS and KMS make a runner prove both its AWS identity and the image it booted before it is trusted.
reads:
- "spin:internal/installation/provider/aws/machine.go@v20261110.01"
- "spin:internal/installation/provider/aws/awscp/join.go@v20261110.01"
- "spin:internal/installation/provider/aws/attestedkey/attestedkey.go@v20261110.01"
- "spin:docs/security/README.md@v20261110.01"
- "spin-terraform-aws:modules/controlplane/config.tf@v20261027.02"
- "spin-terraform-aws:modules/controlplane/attested_key.tf@v20261027.02"
- "spin-terraform-aws:modules/controlplane/identity.tf@v20261027.02"
- "spin-terraform-aws:modules/controlplane/volume_keys.tf@v20261027.02"
---

A runner needs to prove two independent facts before the control plane trusts it:

1. **Which AWS instance is asking.** The instance role signs an STS `GetCallerIdentity` request
for this installation, its audience and its host id.
2. **What that instance booted.** Its NitroTPM returns an AWS-signed attestation document with
the measured boot values (PCR4, PCR7 and PCR12), the request's hash and a public key made for
this boot.

Neither proof is enough by itself. STS does not say what the instance booted; a TPM document does
not say that the machine belongs to this installation. The control plane checks that both describe
the same EC2 instance.

## What OpenTofu deploys

OpenTofu does not enable NitroTPM or distribute a TPM token. NitroTPM is part of the EC2
environment. The module instead declares the evidence it will accept:

| What the module declares | Runtime use |
|---|---|
| The trusted PCR4, PCR7 and PCR12 values for each Spin OS build, with the statement its release signed of them | OpenTofu writes the runner's builds into `host_attestation.runner` of the control plane's document; the control plane admits a runner only as one of them. |
| The runner's instance role and the installation audience | STS identifies the caller and binds its signed request to this installation and host. |
| KMS key policies conditioned on `kms:RecipientAttestation:NitroTPMPCR*` | KMS releases an answer only to a machine whose attestation document carries an allowed complete PCR set. |

The image measurements come from the release's `measurements.json`, or from `image_measurements`
when an installation uses its own images. A release is built again when what is under spin
moves, and each build is a revision of it. The module accepts every revision of the installed
release that is not revoked, so a machine of an earlier build goes on until it is replaced, but a
build removed from that set can no longer join as a runner.

## How a runner is admitted

A runner joins at every boot: its credentials live in `/run`, so each boot creates a new boot key,
signs the STS request with its instance role, and asks NitroTPM to attest to the hash of that
exact request and to the boot public key. The control plane then verifies all of the following
before issuing runner credentials:

- AWS signed the attestation document and it is current.
- Its PCR triplet is one of the trusted runner builds declared by the installation, and that
build carries a statement its release signed of it. A build with nothing signed is not admitted.
- Its nonce is the hash of the signed STS request, so it cannot be reused for another request.
- Its public key is this boot's key, so later runner credentials are tied to the attested boot.
- The STS role is allowed by `host_join`, and the attested module id is the same EC2 instance that
STS identified.

The first join records the host as that instance and the build it booted. Every later boot is
admitted again only from the same instance, proving the same build: a machine that joined as
another host, or proves another build, is not the host that joined. A revoked host stays out
however it proves who it is.

The control plane records the attestation with the runner's boot key before it returns credentials.
That record lets storage readers verify that a key which wrote an object belongs to an admitted,
measured runner image.

## Why KMS checks the same evidence

The admission check decides whether a runner can participate in the control plane. KMS independently
enforces whether it can open sensitive keys. For a volume or workload-identity key, the runner
creates a short-lived recipient key, has NitroTPM attest to its public half, and sends that document
to KMS. KMS encrypts its answer to that recipient only if the document's PCRs match its key policy.

The same pattern protects the control plane's encryption key: at each start it derives the key from
KMS using an attested recipient. The key is not stored in the database, in OpenTofu state or in a
machine image. A control-plane IAM role on a machine that booted an untrusted image is not enough
to obtain it.

## What this protects against

- **Copied IAM credentials on another machine.** They can sign the STS request, but cannot produce
a matching document from an allowed runner's NitroTPM, nor make its module id match that other
machine.
- **A runner booted from a changed disk or image.** Changes affecting the measured kernel, command
line or Secure Boot chain produce a PCR set the installation and KMS policy do not accept.
- **A captured registration proof.** The nonce binds the proof to the signed request, and the boot
key binds it to one boot; the control plane rejects a repeat registration from that boot.
- **An untrusted control-plane image reading installation secrets.** KMS will not return the
installation encryption key to its role without an allowed control-plane attestation.
- **A runner role alone opening volume or identity keys.** A KMS decrypt requires an allowed
recipient attestation as well as the narrowly scoped role/session that requested it.

## What it does not protect against

NitroTPM attestation is a boot-time authorization boundary, not a cure for every compromise. In
particular, it does not prevent malicious code that has already compromised a legitimate, running
runner from using that runner's current authority. It also does not prevent an AWS account
administrator from changing IAM or KMS policy, and it relies on AWS's Nitro attestation root.

The remaining boundaries are therefore still necessary: a verified, read-only Spin OS image;
least-privilege IAM roles and permissions boundaries; per-workspace VM isolation; short-lived
credentials; and KMS policies that name exact PCR combinations. Two limits are spelled out in
[what the security model does not protect](/reference/security/known-gaps/#what-this-does-not-protect):
user data is not measured, so a machine started under the runners' launch template with other user
data might attest as a runner; and a control plane running other code with its own permissions is
outside what attestation holds. See [Supply chain](/security/supply-chain/),
[Data and encryption](/security/data/) and [Known gaps](/reference/security/known-gaps/) for the
adjacent controls and their limits.
1 change: 1 addition & 0 deletions src/lib/nav.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const nav = [
items: [
['Security model', 'security'],
['Isolation', 'security/isolation'],
['NitroTPM and runner authorization', 'security/nitrotpm'],
['Data and encryption', 'security/data'],
['Identity and access', 'security/identity'],
['Supply chain', 'security/supply-chain'],
Expand Down
Loading