From fcbf46faa05f99764f16cfd1a7fe3080a90744ba Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 10 Nov 2026 11:41:26 -0300 Subject: [PATCH 1/3] docs: explain NitroTPM runner authorization --- .../diagrams/nitrotpm-authorisation.svg | 76 ++++++++++++++ src/components/NitroTPMDiagram.astro | 28 ++++++ src/content/docs/security/nitrotpm.md | 99 +++++++++++++++++++ src/lib/nav.mjs | 1 + 4 files changed, 204 insertions(+) create mode 100644 src/assets/diagrams/nitrotpm-authorisation.svg create mode 100644 src/components/NitroTPMDiagram.astro create mode 100644 src/content/docs/security/nitrotpm.md diff --git a/src/assets/diagrams/nitrotpm-authorisation.svg b/src/assets/diagrams/nitrotpm-authorisation.svg new file mode 100644 index 0000000..f79d173 --- /dev/null +++ b/src/assets/diagrams/nitrotpm-authorisation.svg @@ -0,0 +1,76 @@ + + NitroTPM-attested authorization + OpenTofu declares trusted PCR measurements. An EC2 runner proves its AWS identity through STS and its boot measurements through NitroTPM. The control plane verifies both, while KMS releases keys only to matching measurements. + + + + + + + + NitroTPM-attested authorization + + + The installation's AWS account and region + + + OpenTofu + Trusted imagemeasurements + + + AWS STS + Instance identity + + + Control plane + Verifies and admits + + + EC2 runner + IAM role + NitroTPM + + + AWS KMS + Keys only forvalid PCRs + + + trusted PCRs + + STS + TPM attestation + + checks role, audience and instance + + Decrypt with TPM document + + + What it proves + • The instance uses an allowed role + • It booted an image with an approved PCR combination + • The proof belongs to this request and boot key + + + What it blocks + • IAM credentials copied to another machine + • A changed image, disk or boot + • Replaying a captured proof + diff --git a/src/components/NitroTPMDiagram.astro b/src/components/NitroTPMDiagram.astro new file mode 100644 index 0000000..8e4106b --- /dev/null +++ b/src/components/NitroTPMDiagram.astro @@ -0,0 +1,28 @@ +--- +import diagram from '../assets/diagrams/nitrotpm-authorisation.svg?url'; +--- + +
+
+ + OpenTofu declares trusted PCRs; the runner proves its AWS identity and NitroTPM measurements to the control plane, while KMS releases keys only to valid PCRs. + + +
+
How AWS identity, a measured boot and the KMS key policy form one authorization decision.
+
+ + diff --git a/src/content/docs/security/nitrotpm.md b/src/content/docs/security/nitrotpm.md new file mode 100644 index 0000000..e3d1dc0 --- /dev/null +++ b/src/content/docs/security/nitrotpm.md @@ -0,0 +1,99 @@ +--- +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@v20261108.01" + - "spin:internal/installation/provider/aws/awscp/join.go@v20261108.01" + - "spin:internal/installation/provider/aws/attestedkey/attestedkey.go@v20261108.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" +--- + +import NitroTPMDiagram from '../../../components/NitroTPMDiagram.astro'; + +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: + +| Terraform output | Runtime use | +|---|---| +| The trusted PCR4, PCR7 and PCR12 values for each Spin OS build | The control plane writes the runner values into `host_attestation.runner` in its bootstrap document. | +| 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. The module accepts non-revoked builds so an already +running prior build can remain available during an update, but a build removed from that set can +no longer join as a runner. + +## How a runner is admitted + +At first registration, the runner creates a boot key and signs the STS request with its instance +role. It 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. +- 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 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. 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. diff --git a/src/lib/nav.mjs b/src/lib/nav.mjs index f339986..029a712 100644 --- a/src/lib/nav.mjs +++ b/src/lib/nav.mjs @@ -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'], From 64dbdbbe14ff68c0990ff025c43b9ba4a28cfc65 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Wed, 11 Nov 2026 20:29:07 -0300 Subject: [PATCH 2/3] nitrotpm: the picture is the module's, shown as every diagram of the site is The page drew an SVG of its own and a component to show it, which the build refuses: a component the Markdown twin cannot say. The module draws it now, in D2 with the installation's palette and AWS's icons (spin-stack/spin-terraform-aws#110), and the page shows it with Figure, whose words the twin carries. Co-Authored-By: Claude Fable 5.2 --- .../diagrams/nitrotpm-authorisation.svg | 76 ------------------- src/components/NitroTPMDiagram.astro | 28 ------- .../security/{nitrotpm.md => nitrotpm.mdx} | 4 +- 3 files changed, 2 insertions(+), 106 deletions(-) delete mode 100644 src/assets/diagrams/nitrotpm-authorisation.svg delete mode 100644 src/components/NitroTPMDiagram.astro rename src/content/docs/security/{nitrotpm.md => nitrotpm.mdx} (95%) diff --git a/src/assets/diagrams/nitrotpm-authorisation.svg b/src/assets/diagrams/nitrotpm-authorisation.svg deleted file mode 100644 index f79d173..0000000 --- a/src/assets/diagrams/nitrotpm-authorisation.svg +++ /dev/null @@ -1,76 +0,0 @@ - - NitroTPM-attested authorization - OpenTofu declares trusted PCR measurements. An EC2 runner proves its AWS identity through STS and its boot measurements through NitroTPM. The control plane verifies both, while KMS releases keys only to matching measurements. - - - - - - - - NitroTPM-attested authorization - - - The installation's AWS account and region - - - OpenTofu - Trusted imagemeasurements - - - AWS STS - Instance identity - - - Control plane - Verifies and admits - - - EC2 runner - IAM role + NitroTPM - - - AWS KMS - Keys only forvalid PCRs - - - trusted PCRs - - STS + TPM attestation - - checks role, audience and instance - - Decrypt with TPM document - - - What it proves - • The instance uses an allowed role - • It booted an image with an approved PCR combination - • The proof belongs to this request and boot key - - - What it blocks - • IAM credentials copied to another machine - • A changed image, disk or boot - • Replaying a captured proof - diff --git a/src/components/NitroTPMDiagram.astro b/src/components/NitroTPMDiagram.astro deleted file mode 100644 index 8e4106b..0000000 --- a/src/components/NitroTPMDiagram.astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -import diagram from '../assets/diagrams/nitrotpm-authorisation.svg?url'; ---- - -
-
- - OpenTofu declares trusted PCRs; the runner proves its AWS identity and NitroTPM measurements to the control plane, while KMS releases keys only to valid PCRs. - - -
-
How AWS identity, a measured boot and the KMS key policy form one authorization decision.
-
- - diff --git a/src/content/docs/security/nitrotpm.md b/src/content/docs/security/nitrotpm.mdx similarity index 95% rename from src/content/docs/security/nitrotpm.md rename to src/content/docs/security/nitrotpm.mdx index e3d1dc0..45f2250 100644 --- a/src/content/docs/security/nitrotpm.md +++ b/src/content/docs/security/nitrotpm.mdx @@ -10,7 +10,7 @@ reads: - "spin-terraform-aws:modules/controlplane/identity.tf@v20261027.02" --- -import NitroTPMDiagram from '../../../components/NitroTPMDiagram.astro'; +import Figure from '../../../components/Figure.astro'; A runner needs to prove two independent facts before the control plane trusts it: @@ -24,7 +24,7 @@ Neither proof is enough by itself. STS does not say what the instance booted; a not say that the machine belongs to this installation. The control plane checks that both describe the same EC2 instance. - +
## What OpenTofu deploys From 5f4b23da85f009121ff57bffda5b74f36ccd9ce0 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 14:08:41 -0300 Subject: [PATCH 3/3] nitrotpm: the picture waits for the module's release, and the page says what the join does The page showed
, which refgen draws from spin-terraform-aws's docs/diagrams/nitrotpm.svg. That file is on the module's main (21ba51f) and in no release, and the site pins v20261027.02, so the build stopped at /llms-full.txt. Pinning the module at main would move every AWS page onto an unreleased module (24 refgen findings, instance_types among them), so the figure returns with the module's next release. Read against spin v20261110.01 and spin-terraform-aws v20261027.02: - OpenTofu, not the control plane, writes host_attestation.runner (config.tf), each build with what its release signed; a build with nothing signed is not admitted (join.go admitted). - A runner joins at every boot, again only from the same instance and the same build. - The builds accepted are the installed release's revisions not revoked (attested_key.tf). - The limits link spin's "What this does not protect": user data is unmeasured, and a control plane running other code. - reads stamped at v20261110.01, with docs/security/README.md and volume_keys.tf added. Co-Authored-By: Claude Opus 5.5 --- src/content/docs/security/nitrotpm.mdx | 44 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/content/docs/security/nitrotpm.mdx b/src/content/docs/security/nitrotpm.mdx index 45f2250..6b60d71 100644 --- a/src/content/docs/security/nitrotpm.mdx +++ b/src/content/docs/security/nitrotpm.mdx @@ -2,16 +2,16 @@ 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@v20261108.01" - - "spin:internal/installation/provider/aws/awscp/join.go@v20261108.01" - - "spin:internal/installation/provider/aws/attestedkey/attestedkey.go@v20261108.01" + - "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" --- -import Figure from '../../../components/Figure.astro'; - 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 @@ -24,37 +24,43 @@ Neither proof is enough by itself. STS does not say what the instance booted; a 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: -| Terraform output | Runtime use | +| What the module declares | Runtime use | |---|---| -| The trusted PCR4, PCR7 and PCR12 values for each Spin OS build | The control plane writes the runner values into `host_attestation.runner` in its bootstrap document. | +| 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. The module accepts non-revoked builds so an already -running prior build can remain available during an update, but a build removed from that set can -no longer join as a runner. +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 -At first registration, the runner creates a boot key and signs the STS request with its instance -role. It 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: +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. +- 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. @@ -94,6 +100,10 @@ administrator from changing IAM or KMS policy, and it relies on AWS's Nitro atte 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. See [Supply chain](/security/supply-chain/), +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.