Skip to content

Commit 7e2b05c

Browse files
authored
feat(tpm): hold an RSA CA key in the TPM (#282)
* feat(tpm): hold an RSA CA key in the TPM An RSA-3072 or RSA-4096 CA key can now be created and held in the TPM in state_key.mode tpm, loaded through the same RootKeyBackend and key loader as the ECDSA key, with the same fixedTPM, fixedParent and sensitiveDataOrigin attributes. - The key template, public-area parser and signer handle RSA. The key carries a null scheme; Sign takes the hash and padding (PKCS#1 v1.5 or PSS) from crypto.SignerOpts, returns the raw signature, and verifies it before returning it. - CreateKey asks the TPM with TPM2_TestParms whether it implements the size first and returns ErrKeyAlgorithmUnsupported, creating nothing, when it does not. The ceremony and CA-key rotation turn that into FailedPrecondition naming the algorithm. Nothing falls back to a smaller size or to software. - RSA-2048 stays refused for a TPM-held CA key, matching the 3072-bit floor. - Probe reports the RSA sizes the TPM accepts, and boot logs them. - Export stays refused for any TPM-held key; the mode check is now a named helper. The in-process simulator implements RSA-2048 only, so the RSA-3072 path is tested end to end against swtpm (ceremony, reboot and reload, leaf, subordinate, CSR, CRL, OCSP), which CI now installs. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * test(e2e): resend TPM_RC_RETRY in the swtpm transport The Linux TPM driver behind /dev/tpmrm0 resends a command the TPM answers with TPM_RC_RETRY or TPM_RC_TESTING. The test talks to swtpm over a raw socket with no driver, and the libtpms on the CI runner answers the RSA signature in the ceremony with TPM_RC_RETRY, so the test transport now resends as the driver does. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --------- Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 13b649e commit 7e2b05c

18 files changed

Lines changed: 1418 additions & 88 deletions

‎.github/workflows/ci-go.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ jobs:
6060
# built with an older Go panics on newer language/std usage.
6161
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
6262

63+
- name: Install swtpm
64+
# The TPM-held RSA CA end-to-end test needs a TPM that implements
65+
# RSA-3072. The in-process simulator stops at RSA-2048, and the test
66+
# fails rather than skips under CI when swtpm is missing.
67+
run: |
68+
sudo apt-get update
69+
sudo apt-get install -y --no-install-recommends swtpm
70+
6371
- name: Run task ci
6472
run: task ci
6573

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Builds a signed Unified Kernel Image (UKI): hardened kernel + Go-based PID 1 + r
77
## ✨ Architecture at a glance
88

99
- 🪨 **Immutable rootfs** — SquashFS, read-only. Persistent state only on the encrypted partition, unsealed by the local TPM.
10-
- 🔑 **TPM-bound identity** — CA private keys are created inside the TPM and never leave it. ECDSA P-384 for Roots, P-256 for Issuing CAs.
10+
- 🔑 **TPM-bound identity** — CA private keys are created inside the TPM and never leave it. ECDSA P-384 for Roots, P-256 for Issuing CAs. An RSA-3072 or RSA-4096 CA key is held in the TPM the same way when the TPM implements that size; otherwise key creation refuses it rather than fall back.
1111
- 🚫 **No interactive access** — no SSH, no shell, no usernames/passwords. **No web frontend in the image either.** Management is `cryptosctl` over mTLS gRPC, or the Fleet Manager (which talks the same mTLS gRPC).
1212
- 📐 **RFC-strict** — TLS 1.3 (RFC 8446), X.509 (RFC 5280), and every protocol adapter follows its RFC to the letter.
1313
- 📜 **Declarative** — machine config in YAML (`apiVersion: cryptos.dev/v1alpha1`), applied via `ApplyConfig`. No click-ops.
@@ -46,7 +46,7 @@ testdata/configs/ # sample machine configs
4646

4747
## 🛠️ Build + run (dev loop)
4848

49-
Requires Go 1.24+, [`go-task`](https://taskfile.dev), `golangci-lint`, `golic`, and (for integration testing) `qemu-system-x86_64` + `swtpm` + OVMF.
49+
Requires Go 1.24+, [`go-task`](https://taskfile.dev), `golangci-lint`, `golic`, and (for integration testing) `qemu-system-x86_64` + `swtpm` + OVMF. `task test` also runs the TPM-held RSA CA end-to-end test against `swtpm` when it is installed, because the in-process TPM simulator implements RSA-2048 only; without `swtpm` that test skips locally and fails in CI.
5050

5151
```bash
5252
task ci # fmt + lint + vet + test + build (both binaries)
@@ -91,7 +91,7 @@ They are for evaluation with Secure Boot off. A node installed from one cannot b
9191

9292
GitHub Actions:
9393

94-
- **`ci-go`** ([`ci-go.yml`](.github/workflows/ci-go.yml)) — `task ci` (format, lint, vet, test, build) on every pull request + push to `main`, on a GitHub-hosted Linux runner. Draft pull requests are skipped; CI runs when the PR is marked ready.
94+
- **`ci-go`** ([`ci-go.yml`](.github/workflows/ci-go.yml)) — `task ci` (format, lint, vet, test, build) on every pull request + push to `main`, on a GitHub-hosted Linux runner, with `swtpm` installed for the TPM-held RSA CA test. Draft pull requests are skipped; CI runs when the PR is marked ready.
9595
- **`ci-image`** ([`ci-image.yml`](.github/workflows/ci-image.yml)) — builds the UKI on a **GitHub-hosted runner** (amd64 on `ubuntu-latest`, arm64 on `ubuntu-24.04-arm`), installing the kernel / `ukify` / `sbsign` toolchain per run. Runs on push to `main`, tags, and manual dispatch; use `workflow_dispatch` on a branch to validate image changes before merging. On `main` it signs with a per-run ephemeral key as a smoke test and uploads nothing. On a `v*` tag it builds the unsigned [release assets](#release-assets) and attaches them to the tag's release (a draft, marked pre-release for `-alpha`/`-beta`/`-rc` tags, if none exists yet); dispatch with `release_assets` builds them without publishing.
9696

9797
- **`ci-kind-acme`** ([`ci-kind-acme.yml`](.github/workflows/ci-kind-acme.yml)) — `test/kind/run.sh` (the `task e2e:kind` harness) on pull requests that touch the ACME, config, node or e2e code, on a GitHub-hosted runner. Drafts are skipped, and it isn't a required check.

‎docs/vmca-subordination.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,20 @@ answering. The `DNS:` line
119119
gives where the resolver came from (`MACHINE_CONFIG`, `DHCP_LEASE`, or `NONE`)
120120
and the nameservers and search list in use.
121121

122-
RSA CA keys are supported on the software key path (`state_key.mode` of `nodeid`
123-
or `kms`). A TPM-resident RSA CA key is not supported; see #197.
122+
An RSA CA key works in every `state_key.mode`. With `tpm` the key is created
123+
and held in the TPM, like the ECDSA key. The TPM 2.0 spec only requires a TPM
124+
to implement RSA-2048, though, and many parts stop there.
125+
126+
> [!CAUTION]
127+
> On a `tpm` node, check the TPM's datasheet for the RSA size you configure
128+
> before you run the ceremony. If the TPM lacks it, the ceremony stops with
129+
> `FailedPrecondition` naming the algorithm, before any key is created, and you
130+
> can run it again with another `root_key_alg`. An intermediate or issuing
131+
> node makes its key at first boot instead, and its boot stops on the same
132+
> error. It never falls back to a software key or a smaller size. The boot log's `init: TPM capabilities:` line
133+
> lists the RSA sizes the TPM accepted. If yours is missing, use a node whose TPM
134+
> has it, or the `nodeid` or `kms` mode, where the key is software-held. On a
135+
> hardware TPM, generating an RSA key can take tens of seconds.
124136

125137
## The procedure
126138

‎internal/ceremony/ceremony.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"errors"
3131
"fmt"
3232
"io"
33+
"log"
3334
"sync"
3435
"time"
3536

@@ -215,7 +216,15 @@ func (e *Engine) Start(ctx context.Context, req *cryptosv1.StartCeremonyRequest,
215216
if err := e.cfg.RootKey.ProvisionSRK(); err != nil {
216217
return status.Errorf(codes.Internal, "ceremony: provision SRK: %v", err)
217218
}
219+
// An RSA key generated inside a TPM can take tens of seconds.
220+
log.Printf("ceremony: creating the %s root key", cfg.PKI.RootKeyAlg)
218221
created, err := e.cfg.RootKey.CreateKey(keyAlg)
222+
if errors.Is(err, tpm.ErrKeyAlgorithmUnsupported) {
223+
log.Printf("ceremony: refused: pki.root_key_alg %s is not implemented by this TPM", cfg.PKI.RootKeyAlg)
224+
return status.Errorf(codes.FailedPrecondition,
225+
"ceremony: create key: %v (set pki.root_key_alg to an algorithm this TPM implements, such as ECDSA-P384, or use a node whose TPM implements %s)",
226+
err, cfg.PKI.RootKeyAlg)
227+
}
219228
if err != nil {
220229
return status.Errorf(codes.Internal, "ceremony: create key: %v", err)
221230
}

‎internal/ceremony/rsa_tpm_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package ceremony
2+
3+
/*
4+
Apache License 2.0
5+
6+
Copyright 2026 Shane
7+
8+
Licensed under the Apache License, Version 2.0 (the "License");
9+
you may not use this file except in compliance with the License.
10+
You may obtain a copy of the License at
11+
12+
http://www.apache.org/licenses/LICENSE-2.0
13+
14+
Unless required by applicable law or agreed to in writing, software
15+
distributed under the License is distributed on an "AS IS" BASIS,
16+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
See the License for the specific language governing permissions and
18+
limitations under the License.
19+
*/
20+
21+
import (
22+
"bytes"
23+
"strings"
24+
"testing"
25+
26+
"google.golang.org/grpc/codes"
27+
"google.golang.org/grpc/status"
28+
29+
cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1"
30+
)
31+
32+
// TestStart_RSAOnTPMWithoutSize_FailsPrecondition runs the root ceremony with
33+
// root_key_alg RSA-3072 against the in-process simulator, which, like many
34+
// real TPM parts, implements RSA-2048 only. The ceremony must stop with
35+
// FailedPrecondition naming the algorithm, emit nothing, commit no identity,
36+
// and leave the node able to run the ceremony again with an algorithm the TPM
37+
// has. It must never fall back to a software key or a smaller size.
38+
func TestStart_RSAOnTPMWithoutSize_FailsPrecondition(t *testing.T) {
39+
h, ctx := newHarness(t)
40+
c := &collector{}
41+
rsaYAML := bytes.Replace(machineYAML(h.adminFP), []byte("root_key_alg: ECDSA-P384"), []byte("root_key_alg: RSA-3072"), 1)
42+
43+
err := h.engine.Start(ctx, &cryptosv1.StartCeremonyRequest{
44+
Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT,
45+
MachineConfigYaml: rsaYAML,
46+
}, c.send)
47+
if status.Code(err) != codes.FailedPrecondition {
48+
t.Fatalf("Start(RSA-3072 on a TPM without it): code = %v, want FailedPrecondition (err=%v)", status.Code(err), err)
49+
}
50+
if !strings.Contains(err.Error(), "RSA-3072") {
51+
t.Errorf("error %q does not name RSA-3072", err)
52+
}
53+
if len(c.kinds) != 0 {
54+
t.Errorf("events emitted on a refused ceremony: %v", c.kinds)
55+
}
56+
if ok, _ := h.store.HasIdentity(ctx); ok {
57+
t.Fatal("identity established despite the TPM lacking RSA-3072")
58+
}
59+
60+
retry := &collector{}
61+
if err := h.engine.Start(ctx, &cryptosv1.StartCeremonyRequest{
62+
Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT,
63+
MachineConfigYaml: machineYAML(h.adminFP),
64+
}, retry.send); err != nil {
65+
t.Fatalf("retry with ECDSA-P384: %v", err)
66+
}
67+
assertOrder(t, retry.kinds, wantOrder())
68+
}

0 commit comments

Comments
 (0)