From 820d8ea0d7c0b1c89292d1e6167eef9a12ac18cc Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:35:13 -0400 Subject: [PATCH 1/2] 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> --- .github/workflows/ci-go.yml | 8 + README.md | 6 +- docs/vmca-subordination.md | 16 +- internal/ceremony/ceremony.go | 9 + internal/ceremony/rsa_tpm_test.go | 68 ++++ internal/e2e/tpm_rsa_e2e_test.go | 497 ++++++++++++++++++++++++++++++ internal/init/escrow.go | 12 +- internal/init/rekey.go | 6 + internal/init/rootbackend.go | 6 + internal/init/run.go | 3 +- internal/init/tpm_rsa_test.go | 126 ++++++++ internal/tpm/capabilities.go | 44 ++- internal/tpm/doc.go | 16 +- internal/tpm/key.go | 188 +++++++++-- internal/tpm/keyalg_test.go | 29 +- internal/tpm/rsa_test.go | 287 +++++++++++++++++ internal/tpm/signer.go | 149 +++++++-- internal/tpm/tpm.go | 7 + 18 files changed, 1389 insertions(+), 88 deletions(-) create mode 100644 internal/ceremony/rsa_tpm_test.go create mode 100644 internal/e2e/tpm_rsa_e2e_test.go create mode 100644 internal/init/tpm_rsa_test.go create mode 100644 internal/tpm/rsa_test.go diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index c01d1a0..43dea5e 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -60,6 +60,14 @@ jobs: # built with an older Go panics on newer language/std usage. run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + - name: Install swtpm + # The TPM-held RSA CA end-to-end test needs a TPM that implements + # RSA-3072. The in-process simulator stops at RSA-2048, and the test + # fails rather than skips under CI when swtpm is missing. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends swtpm + - name: Run task ci run: task ci diff --git a/README.md b/README.md index 029dd8d..bc47d26 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Builds a signed Unified Kernel Image (UKI): hardened kernel + Go-based PID 1 + r ## ✨ Architecture at a glance - 🪨 **Immutable rootfs** — SquashFS, read-only. Persistent state only on the encrypted partition, unsealed by the local TPM. -- 🔑 **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. +- 🔑 **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. - 🚫 **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). - 📐 **RFC-strict** — TLS 1.3 (RFC 8446), X.509 (RFC 5280), and every protocol adapter follows its RFC to the letter. - 📜 **Declarative** — machine config in YAML (`apiVersion: cryptos.dev/v1alpha1`), applied via `ApplyConfig`. No click-ops. @@ -46,7 +46,7 @@ testdata/configs/ # sample machine configs ## 🛠️ Build + run (dev loop) -Requires Go 1.24+, [`go-task`](https://taskfile.dev), `golangci-lint`, `golic`, and (for integration testing) `qemu-system-x86_64` + `swtpm` + OVMF. +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. ```bash 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 GitHub Actions: -- **`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. +- **`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. - **`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. - **`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. diff --git a/docs/vmca-subordination.md b/docs/vmca-subordination.md index 45a0d7d..f0936e7 100644 --- a/docs/vmca-subordination.md +++ b/docs/vmca-subordination.md @@ -119,8 +119,20 @@ answering. The `DNS:` line gives where the resolver came from (`MACHINE_CONFIG`, `DHCP_LEASE`, or `NONE`) and the nameservers and search list in use. -RSA CA keys are supported on the software key path (`state_key.mode` of `nodeid` -or `kms`). A TPM-resident RSA CA key is not supported; see #197. +An RSA CA key works in every `state_key.mode`. With `tpm` the key is created +and held in the TPM, like the ECDSA key. The TPM 2.0 spec only requires a TPM +to implement RSA-2048, though, and many parts stop there. + +> [!CAUTION] +> On a `tpm` node, check the TPM's datasheet for the RSA size you configure +> before you run the ceremony. If the TPM lacks it, the ceremony stops with +> `FailedPrecondition` naming the algorithm, before any key is created, and you +> can run it again with another `root_key_alg`. An intermediate or issuing +> node makes its key at first boot instead, and its boot stops on the same +> error. It never falls back to a software key or a smaller size. The boot log's `init: TPM capabilities:` line +> lists the RSA sizes the TPM accepted. If yours is missing, use a node whose TPM +> has it, or the `nodeid` or `kms` mode, where the key is software-held. On a +> hardware TPM, generating an RSA key can take tens of seconds. ## The procedure diff --git a/internal/ceremony/ceremony.go b/internal/ceremony/ceremony.go index 4f6a24f..f068c0b 100644 --- a/internal/ceremony/ceremony.go +++ b/internal/ceremony/ceremony.go @@ -30,6 +30,7 @@ import ( "errors" "fmt" "io" + "log" "sync" "time" @@ -215,7 +216,15 @@ func (e *Engine) Start(ctx context.Context, req *cryptosv1.StartCeremonyRequest, if err := e.cfg.RootKey.ProvisionSRK(); err != nil { return status.Errorf(codes.Internal, "ceremony: provision SRK: %v", err) } + // An RSA key generated inside a TPM can take tens of seconds. + log.Printf("ceremony: creating the %s root key", cfg.PKI.RootKeyAlg) created, err := e.cfg.RootKey.CreateKey(keyAlg) + if errors.Is(err, tpm.ErrKeyAlgorithmUnsupported) { + log.Printf("ceremony: refused: pki.root_key_alg %s is not implemented by this TPM", cfg.PKI.RootKeyAlg) + return status.Errorf(codes.FailedPrecondition, + "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)", + err, cfg.PKI.RootKeyAlg) + } if err != nil { return status.Errorf(codes.Internal, "ceremony: create key: %v", err) } diff --git a/internal/ceremony/rsa_tpm_test.go b/internal/ceremony/rsa_tpm_test.go new file mode 100644 index 0000000..a24c1b5 --- /dev/null +++ b/internal/ceremony/rsa_tpm_test.go @@ -0,0 +1,68 @@ +package ceremony + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// TestStart_RSAOnTPMWithoutSize_FailsPrecondition runs the root ceremony with +// root_key_alg RSA-3072 against the in-process simulator, which, like many +// real TPM parts, implements RSA-2048 only. The ceremony must stop with +// FailedPrecondition naming the algorithm, emit nothing, commit no identity, +// and leave the node able to run the ceremony again with an algorithm the TPM +// has. It must never fall back to a software key or a smaller size. +func TestStart_RSAOnTPMWithoutSize_FailsPrecondition(t *testing.T) { + h, ctx := newHarness(t) + c := &collector{} + rsaYAML := bytes.Replace(machineYAML(h.adminFP), []byte("root_key_alg: ECDSA-P384"), []byte("root_key_alg: RSA-3072"), 1) + + err := h.engine.Start(ctx, &cryptosv1.StartCeremonyRequest{ + Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, + MachineConfigYaml: rsaYAML, + }, c.send) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("Start(RSA-3072 on a TPM without it): code = %v, want FailedPrecondition (err=%v)", status.Code(err), err) + } + if !strings.Contains(err.Error(), "RSA-3072") { + t.Errorf("error %q does not name RSA-3072", err) + } + if len(c.kinds) != 0 { + t.Errorf("events emitted on a refused ceremony: %v", c.kinds) + } + if ok, _ := h.store.HasIdentity(ctx); ok { + t.Fatal("identity established despite the TPM lacking RSA-3072") + } + + retry := &collector{} + if err := h.engine.Start(ctx, &cryptosv1.StartCeremonyRequest{ + Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, + MachineConfigYaml: machineYAML(h.adminFP), + }, retry.send); err != nil { + t.Fatalf("retry with ECDSA-P384: %v", err) + } + assertOrder(t, retry.kinds, wantOrder()) +} diff --git a/internal/e2e/tpm_rsa_e2e_test.go b/internal/e2e/tpm_rsa_e2e_test.go new file mode 100644 index 0000000..b97ab07 --- /dev/null +++ b/internal/e2e/tpm_rsa_e2e_test.go @@ -0,0 +1,497 @@ +package e2e + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This file proves an RSA CA key held in a TPM end to end: the real first-boot +// ceremony creates an RSA-3072 root key inside the TPM, the node "reboots" +// (the TPM process restarts on its persisted state), and the key reloaded +// through the production backend signs a leaf, a subordinate CA, a CSR, a CRL +// and a delegated OCSP responder. +// +// The in-process simulator the unit tests use implements RSA-2048 only, so +// this runs against swtpm (libtpms), which implements RSA-3072. It skips when +// swtpm is not installed, except in CI, where a missing swtpm is a failure so +// the RSA TPM path can never silently stop being tested. + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/google/go-tpm/tpm2" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/bootstrap" + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/ceremony" + "github.com/CryptOS-PKI/cryptos/internal/config" + cinit "github.com/CryptOS-PKI/cryptos/internal/init" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/revocation" + "github.com/CryptOS-PKI/cryptos/internal/storage/etcd" + "github.com/CryptOS-PKI/cryptos/internal/tpm" +) + +// swtpmSocket is a transport.TPMCloser over swtpm's raw command socket: one +// command out, one length-prefixed response back. +type swtpmSocket struct{ conn net.Conn } + +func (s *swtpmSocket) Send(cmd []byte) ([]byte, error) { + if _, err := s.conn.Write(cmd); err != nil { + return nil, err + } + header := make([]byte, 10) + if _, err := io.ReadFull(s.conn, header); err != nil { + return nil, err + } + size := binary.BigEndian.Uint32(header[2:6]) + if size < 10 { + return nil, fmt.Errorf("swtpm: response size %d is shorter than its header", size) + } + rsp := make([]byte, size) + copy(rsp, header) + if _, err := io.ReadFull(s.conn, rsp[10:]); err != nil { + return nil, err + } + return rsp, nil +} + +func (s *swtpmSocket) Close() error { return s.conn.Close() } + +// swtpmNode is one run of an swtpm process over a state directory that +// survives restarts, standing in for a node's TPM across reboots. +type swtpmNode struct { + t *testing.T + stateDir string + cmd *exec.Cmd + tpm *tpm.TPM +} + +// newSWTPMNode returns a node with swtpm started, or skips when swtpm is not +// installed outside CI. +func newSWTPMNode(t *testing.T) *swtpmNode { + t.Helper() + if _, err := exec.LookPath("swtpm"); err != nil { + if os.Getenv("CI") != "" { + t.Fatal("swtpm is not installed; CI must install it so the TPM-held RSA CA path is tested") + } + t.Skip("swtpm not installed; the TPM-held RSA-3072 CA path needs a TPM that implements RSA-3072") + } + // A short path: a unix socket path is capped near 104 bytes on macOS, and + // t.TempDir embeds the test name. + dir, err := os.MkdirTemp("", "swtpm-") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + n := &swtpmNode{t: t, stateDir: dir} + n.start() + t.Cleanup(n.stop) + return n +} + +func (n *swtpmNode) start() { + n.t.Helper() + sock := filepath.Join(n.stateDir, "cmd.sock") + _ = os.Remove(sock) + cmd := exec.Command("swtpm", "socket", "--tpm2", + "--tpmstate", "dir="+n.stateDir, + "--server", "type=unixio,path="+sock, + "--ctrl", "type=unixio,path="+filepath.Join(n.stateDir, "ctrl.sock"), + "--flags", "not-need-init,startup-clear") + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + n.t.Fatalf("start swtpm: %v", err) + } + n.cmd = cmd + + var conn net.Conn + deadline := time.Now().Add(10 * time.Second) + for { + var err error + if conn, err = net.Dial("unix", sock); err == nil { + break + } + if time.Now().After(deadline) { + n.stop() + n.t.Fatalf("swtpm socket never came up: %v (stderr: %s)", err, stderr.String()) + } + time.Sleep(50 * time.Millisecond) + } + n.tpm = tpm.OpenTransport(&swtpmSocket{conn: conn}) +} + +func (n *swtpmNode) stop() { + if n.tpm != nil { + _ = n.tpm.Close() + n.tpm = nil + } + if n.cmd != nil { + // swtpm writes its NV state (the persisted SRK) as it changes, so a + // kill after a grace period loses nothing a reboot needs. Some builds + // do not exit on SIGTERM once their client has gone. + _ = n.cmd.Process.Signal(syscall.SIGTERM) + exited := make(chan struct{}) + go func() { _ = n.cmd.Wait(); close(exited) }() + select { + case <-exited: + case <-time.After(3 * time.Second): + _ = n.cmd.Process.Kill() + <-exited + } + n.cmd = nil + } +} + +// reboot restarts swtpm on the same state, as a node reboot restarts its TPM. +func (n *swtpmNode) reboot() { + n.t.Helper() + n.stop() + n.start() +} + +func newRSATPMStore(t *testing.T) *node.Store { + t.Helper() + srv, err := etcd.Open(t.TempDir()) + if err != nil { + t.Fatalf("etcd.Open: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + cli, err := srv.Client() + if err != nil { + t.Fatalf("etcd.Client: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + s, err := node.New(cli) + if err != nil { + t.Fatalf("node.New: %v", err) + } + return s +} + +// rsaTPMRootYAML is the operator's root MachineConfig with an RSA-3072 CA key. +// The node's state-key mode is not part of it: which backend holds the key is +// the ceremony's RootKey, here the tpm-mode backend. +func rsaTPMRootYAML(adminFP [32]byte) []byte { + return []byte(fmt.Sprintf(`apiVersion: cryptos.dev/v1alpha1 +kind: MachineConfig +metadata: + name: rsa-tpm-root +role: + kind: root +network: + interface: eth0 + address: 10.0.0.10/24 + gateway: 10.0.0.1 +bootstrap: + admin_cert_sha256: "%s" +pki: + root_key_alg: RSA-3072 + root_subject: + common_name: "ACME RSA Root CA" + organization: "ACME" + country: "US" + root_validity_years: 20 + path_len_constraint: 1 +`, hex.EncodeToString(adminFP[:]))) +} + +// loadCAKey reloads the CA key the way production's keyLoader does: the +// persisted blobs, through the tpm-mode RootKeyBackend. +func loadCAKey(t *testing.T, ctx context.Context, store *node.Store, tp *tpm.TPM) crypto.Signer { + t.Helper() + priv, pub, ok, err := store.RootKeyBlobs(ctx) + if err != nil || !ok { + t.Fatalf("RootKeyBlobs ok=%v err=%v", ok, err) + } + signer, err := cinit.NewTPMRootBackend(tp).LoadKey(priv, pub) + if err != nil { + t.Fatalf("LoadKey: %v", err) + } + t.Cleanup(func() { _ = signer.Close() }) + return signer +} + +func TestTPMHeldRSACAEndToEnd(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + now := time.Now().UTC().Truncate(time.Second) + + tpmNode := newSWTPMNode(t) + caps, err := tpmNode.tpm.Probe() + if err != nil { + t.Fatalf("Probe: %v", err) + } + if !caps.SupportsRSAKeyBits(3072) { + t.Fatalf("this swtpm does not implement RSA-3072 (RSA sizes %v); libtpms 0.8 or later is needed", caps.RSAKeyBits) + } + + // Step 1: the real first-boot ceremony, tpm mode, RSA-3072. + store := newRSATPMStore(t) + adminFP := sha256.Sum256([]byte("TPM-held RSA CA bootstrap admin")) + trust, err := bootstrap.LoadTrust("", hex.EncodeToString(adminFP[:])) + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + seed := make([]byte, ceremony.SeedLength) + if _, err := rand.Read(seed); err != nil { + t.Fatalf("seed: %v", err) + } + eng, err := ceremony.New(ceremony.Config{ + RootKey: cinit.NewTPMRootBackend(tpmNode.tpm), + Store: store, + ConfigStore: config.NewFileStore(t.TempDir()), + Trust: trust, + Seed: seed, + }) + if err != nil { + t.Fatalf("ceremony.New: %v", err) + } + rsaYAML := rsaTPMRootYAML(adminFP) + if err := eng.Start(ctx, &cryptosv1.StartCeremonyRequest{ + Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, + MachineConfigYaml: rsaYAML, + }, func(*cryptosv1.StartCeremonyResponse) error { return nil }); err != nil { + t.Fatalf("ceremony Start (RSA-3072, tpm mode): %v", err) + } + + id, err := store.Identity(ctx) + if err != nil { + t.Fatalf("Identity: %v", err) + } + rootCert, err := x509.ParseCertificate(id.ChainDer[0]) + if err != nil { + t.Fatalf("parse root: %v", err) + } + rootPub, ok := rootCert.PublicKey.(*rsa.PublicKey) + if !ok || rootPub.N.BitLen() != 3072 { + t.Fatalf("root public key = %T (%v), want a 3072-bit RSA key", rootCert.PublicKey, rootCert.PublicKeyAlgorithm) + } + if rootCert.SignatureAlgorithm != x509.SHA384WithRSA { + t.Errorf("root SignatureAlgorithm = %v, want SHA384-RSA", rootCert.SignatureAlgorithm) + } + if err := rootCert.CheckSignatureFrom(rootCert); err != nil { + t.Fatalf("root does not self-verify: %v", err) + } + + // The persisted key is a TPM object with the ECDSA key's properties, not a + // software key: generated in, and never movable off, this TPM. + _, pubBlob, _, err := store.RootKeyBlobs(ctx) + if err != nil { + t.Fatalf("RootKeyBlobs: %v", err) + } + tpmPub, err := tpm2.Unmarshal[tpm2.TPM2BPublic](pubBlob) + if err != nil { + t.Fatalf("stored public blob is not a TPM2B_PUBLIC: %v", err) + } + area, err := tpmPub.Contents() + if err != nil { + t.Fatalf("public contents: %v", err) + } + if area.Type != tpm2.TPMAlgRSA || !area.ObjectAttributes.FixedTPM || !area.ObjectAttributes.FixedParent || !area.ObjectAttributes.SensitiveDataOrigin { + t.Fatalf("stored key type 0x%x attributes %+v: want an RSA key with fixedTPM, fixedParent and sensitiveDataOrigin", area.Type, area.ObjectAttributes) + } + + // Step 2: reboot, then reload through the production backend. + tpmNode.reboot() + caKey := loadCAKey(t, ctx, store, tpmNode.tpm) + if !rootPub.Equal(caKey.Public()) { + t.Fatal("the reloaded TPM key is not the key the root certificate names") + } + + t.Run("leaf", func(t *testing.T) { + leafDER, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: "host.acme.example"}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, 90), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"host.acme.example"}, + }, rsaSubjectKey(t).Public(), rootCert, caKey) + if err != nil { + t.Fatalf("Sign leaf: %v", err) + } + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + if !rsaSHA2SigAlgs[leaf.SignatureAlgorithm] { + t.Errorf("leaf SignatureAlgorithm = %v, want SHA-2 RSA", leaf.SignatureAlgorithm) + } + pool := x509.NewCertPool() + pool.AddCert(rootCert) + if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, CurrentTime: now.Add(time.Hour), DNSName: "host.acme.example"}); err != nil { + t.Fatalf("leaf does not chain to the TPM-held root: %v", err) + } + }) + + t.Run("subordinate", func(t *testing.T) { + pathLen := 0 + subDER, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: "ACME RSA Issuing CA", Organization: []string{"ACME"}}, + NotBefore: now, + NotAfter: now.AddDate(5, 0, 0), + IsCA: true, + PathLen: &pathLen, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + }, rsaSubjectKey(t).Public(), rootCert, caKey) + if err != nil { + t.Fatalf("Sign subordinate: %v", err) + } + sub, err := x509.ParseCertificate(subDER) + if err != nil { + t.Fatalf("parse subordinate: %v", err) + } + if !sub.IsCA || !rsaSHA2SigAlgs[sub.SignatureAlgorithm] { + t.Errorf("subordinate IsCA=%v SignatureAlgorithm=%v, want a CA with a SHA-2 RSA signature", sub.IsCA, sub.SignatureAlgorithm) + } + if err := sub.CheckSignatureFrom(rootCert); err != nil { + t.Fatalf("subordinate CheckSignatureFrom root: %v", err) + } + }) + + t.Run("csr", func(t *testing.T) { + // A subordinate node's TPM key signs its own PKCS#10 request. + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "ACME RSA Intermediate CA"}, + }, caKey) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + t.Fatalf("ParseCertificateRequest: %v", err) + } + if err := csr.CheckSignature(); err != nil { + t.Fatalf("CSR signature: %v", err) + } + }) + + revStore, _ := newRevocationStore(t) + leafDER, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: "revoked.acme.example"}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, 90), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, rsaSubjectKey(t).Public(), rootCert, caKey) + if err != nil { + t.Fatalf("Sign leaf to revoke: %v", err) + } + leafCert, err := x509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("parse leaf to revoke: %v", err) + } + serialHex := leafCert.SerialNumber.Text(16) + if err := revStore.RecordIssued(ctx, revocation.IssuedRecord{SerialHex: serialHex, NotAfter: leafCert.NotAfter}); err != nil { + t.Fatalf("RecordIssued: %v", err) + } + if _, err := revStore.Revoke(ctx, serialHex, ocsp.KeyCompromise, now); err != nil { + t.Fatalf("Revoke: %v", err) + } + + t.Run("crl", func(t *testing.T) { + crlDER, err := revocation.NewCRLBuilder(revStore, 168*time.Hour).Build(ctx, rootCert, caKey, now.Add(time.Minute)) + if err != nil { + t.Fatalf("Build CRL: %v", err) + } + crl, err := x509.ParseRevocationList(crlDER) + if err != nil { + t.Fatalf("ParseRevocationList: %v", err) + } + if !rsaSHA2SigAlgs[crl.SignatureAlgorithm] { + t.Errorf("CRL SignatureAlgorithm = %v, want SHA-2 RSA", crl.SignatureAlgorithm) + } + if err := crl.CheckSignatureFrom(rootCert); err != nil { + t.Fatalf("CRL CheckSignatureFrom: %v", err) + } + if len(crl.RevokedCertificateEntries) != 1 || crl.RevokedCertificateEntries[0].SerialNumber.Text(16) != serialHex { + t.Fatalf("CRL entries = %v, want the one revoked serial %s", crl.RevokedCertificateEntries, serialHex) + } + }) + + t.Run("ocsp", func(t *testing.T) { + responderKey := rsaSubjectKey(t) + responderDER, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: "ACME RSA Root CA OCSP Responder"}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, 7), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, + ExtraExtensions: []pkix.Extension{{ + Id: oidOCSPNoCheckE2E, + Value: []byte{0x05, 0x00}, + }}, + }, responderKey.Public(), rootCert, caKey) + if err != nil { + t.Fatalf("Sign responder: %v", err) + } + responderCert, err := x509.ParseCertificate(responderDER) + if err != nil { + t.Fatalf("parse responder: %v", err) + } + reqDER, err := ocsp.CreateRequest(leafCert, rootCert, nil) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + respDER, err := revocation.NewOCSPResponder(revStore).Respond(ctx, reqDER, rootCert, responderCert, responderKey, now.Add(time.Minute)) + if err != nil { + t.Fatalf("Respond: %v", err) + } + // ParseResponse checks the responder certificate was issued by the + // TPM-held root, as well as the response signature. + resp, err := ocsp.ParseResponse(respDER, rootCert) + if err != nil { + t.Fatalf("ParseResponse: %v", err) + } + if resp.Status != ocsp.Revoked { + t.Errorf("Status = %d, want Revoked (%d)", resp.Status, ocsp.Revoked) + } + }) + + // A second ceremony on the same node is refused: the TPM key is the node's + // identity from here on. + err = eng.Start(ctx, &cryptosv1.StartCeremonyRequest{ + Kind: cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, + MachineConfigYaml: rsaYAML, + }, func(*cryptosv1.StartCeremonyResponse) error { return nil }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("second ceremony = %v, want FailedPrecondition IDENTITY_EXISTS", err) + } +} diff --git a/internal/init/escrow.go b/internal/init/escrow.go index d44d9bb..782f6b6 100644 --- a/internal/init/escrow.go +++ b/internal/init/escrow.go @@ -28,6 +28,7 @@ import ( cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" "github.com/CryptOS-PKI/cryptos/internal/backup" + "github.com/CryptOS-PKI/cryptos/internal/config" cgrpc "github.com/CryptOS-PKI/cryptos/internal/grpc" "github.com/CryptOS-PKI/cryptos/internal/node" ) @@ -62,8 +63,15 @@ type caEscrow struct { exportable bool } -// newCAEscrow builds a caEscrow over the store. exportable must be derived from -// the state-key mode (true for nodeid/kms, false for tpm). +// caKeyExportable reports whether the CA key under the given state-key mode is +// software-backed and so can be escrowed. A TPM-held key, ECDSA or RSA, is +// created fixedTPM and never exportable. +func caKeyExportable(mode string) bool { + return mode != config.StateKeyModeTPM +} + +// newCAEscrow builds a caEscrow over the store. exportable must come from +// caKeyExportable for the node's state-key mode. func newCAEscrow(store escrowStore, exportable bool) *caEscrow { return &caEscrow{store: store, exportable: exportable} } diff --git a/internal/init/rekey.go b/internal/init/rekey.go index 7a73ead..f6a3ad0 100644 --- a/internal/init/rekey.go +++ b/internal/init/rekey.go @@ -21,6 +21,7 @@ limitations under the License. import ( "context" "errors" + "log" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -30,6 +31,7 @@ import ( "github.com/CryptOS-PKI/cryptos/internal/config" cgrpc "github.com/CryptOS-PKI/cryptos/internal/grpc" "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/tpm" ) // rekeyStore is the slice of node.Store the rekeyer uses. It keeps the rekeyer @@ -104,6 +106,10 @@ func (r *nodeRekeyer) BeginRotation(ctx context.Context) ([]byte, error) { return nil, status.Errorf(codes.InvalidArgument, "init: %v", err) } created, err := r.backend.CreateKey(keyAlg) + if errors.Is(err, tpm.ErrKeyAlgorithmUnsupported) { + log.Printf("init: key rotation refused: pki.root_key_alg %s is not implemented by this TPM", r.cfg.PKI.RootKeyAlg) + return nil, status.Errorf(codes.FailedPrecondition, "init: create rotation key: %v", err) + } if err != nil { return nil, status.Errorf(codes.Internal, "init: create rotation key: %v", err) } diff --git a/internal/init/rootbackend.go b/internal/init/rootbackend.go index 42a6be4..0556e08 100644 --- a/internal/init/rootbackend.go +++ b/internal/init/rootbackend.go @@ -23,6 +23,12 @@ import ( "github.com/CryptOS-PKI/cryptos/internal/tpm" ) +// NewTPMRootBackend returns the TPM Root-key backend production selects in tpm +// mode (see run.go newStateKeyBackends) as a ceremony.RootKeyBackend, so an +// end-to-end test can drive the real ceremony and key loader against a +// software TPM without exporting the concrete type. +func NewTPMRootBackend(t *tpm.TPM) ceremony.RootKeyBackend { return tpmRootBackend{t} } + // tpmRootBackend adapts *tpm.TPM to ceremony.RootKeyBackend. The Root key is // created in and non-exportable from the TPM (default, hardware-backed). type tpmRootBackend struct{ t *tpm.TPM } diff --git a/internal/init/run.go b/internal/init/run.go index 7cf1497..72d0c31 100644 --- a/internal/init/run.go +++ b/internal/init/run.go @@ -131,6 +131,7 @@ func newStateKeyBackends(mode string, sk config.StateKey) (StateKeyProtector, ce _ = tp.Close() return nil, nil, func() {}, cryptosv1.TpmState_TPM_STATE_UNAVAILABLE, fmt.Errorf("init: probe TPM: %w", err) } + log.Printf("init: TPM capabilities: ECC curves %v, RSA key sizes %v", caps.LoadedCurves, caps.RSAKeyBits) if !caps.SupportsCurve(tpm2.TPMECCNistP384) { _ = tp.Close() return nil, nil, func() {}, cryptosv1.TpmState_TPM_STATE_INSUFFICIENT_CAPABILITY, @@ -588,7 +589,7 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { // software-backed (nodeID/KMS state-key modes); a TPM-sealed key is // non-exportable, so export is refused in tpm mode. It is wired only into the // management listeners below (local + mTLS), never the maintenance servers. - escrow := newCAEscrow(store, mode != config.StateKeyModeTPM) + escrow := newCAEscrow(store, caKeyExportable(mode)) // In-place image upgrade (#208), so replacing the OS stops meaning a // re-provision that destroys the CA key. It needs two things this is the diff --git a/internal/init/tpm_rsa_test.go b/internal/init/tpm_rsa_test.go new file mode 100644 index 0000000..f93a92e --- /dev/null +++ b/internal/init/tpm_rsa_test.go @@ -0,0 +1,126 @@ +package init + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "strings" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/CryptOS-PKI/cryptos/internal/bootstrap" + "github.com/CryptOS-PKI/cryptos/internal/config" + cgrpc "github.com/CryptOS-PKI/cryptos/internal/grpc" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/tpm" +) + +func TestCAKeyExportableOnlyOffTPM(t *testing.T) { + for mode, want := range map[string]bool{ + config.StateKeyModeTPM: false, + config.StateKeyModeNodeID: true, + config.StateKeyModeKMS: true, + } { + if got := caKeyExportable(mode); got != want { + t.Errorf("caKeyExportable(%q) = %v, want %v", mode, got, want) + } + } +} + +// TestExportRefusedForTPMRSACA pins that an RSA CA on a TPM node is as +// non-exportable as the ECDSA one: the refusal follows the state-key mode, not +// the key algorithm. +func TestExportRefusedForTPMRSACA(t *testing.T) { + s, ctx := newEscrowStore(t) + key, err := rsa.GenerateKey(rand.Reader, 3072) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "RSA TPM Root"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + // On a TPM node the stored blobs are the TPM-wrapped key, which escrow + // never gets as far as reading; opaque bytes stand in for them here. + commitCA(t, s, ctx, []byte("tpm-wrapped-private"), []byte("tpm-public"), [][]byte{der}) + + exp := newCAEscrow(s, caKeyExportable(config.StateKeyModeTPM)) + if _, err := exp.ExportCAKey(ctx, []byte("operator-passphrase")); !errors.Is(err, cgrpc.ErrNotExportable) { + t.Fatalf("ExportCAKey for a TPM-held RSA CA = %v, want ErrNotExportable", err) + } +} + +// TestBeginRotation_RSAOnTPMWithoutSize_FailsPrecondition: CA-key rotation +// creates a new key through the same backend as the ceremony, so on a TPM +// without the configured RSA size (the in-process simulator implements +// RSA-2048 only) it must refuse the same way, and stage nothing. +func TestBeginRotation_RSAOnTPMWithoutSize_FailsPrecondition(t *testing.T) { + s, ctx := newRekeyStore(t) + keyBlob, keyPub, chain := makeSoftCA(t, "Child Issuing CA") + commitCA(t, s, ctx, keyBlob, keyPub, chain) + + parent := newRekeyParent(t) + trust, err := bootstrap.LoadTrust(certPEM(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + enr, err := node.NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + sim, err := tpm.OpenSimulator() + if err != nil { + t.Fatalf("OpenSimulator: %v", err) + } + t.Cleanup(func() { _ = sim.Close() }) + + cfg := rekeyConfig() + cfg.PKI.RootKeyAlg = config.RootKeyRSA3072 + r, err := newRekeyer(s, NewTPMRootBackend(sim), cfg, enr) + if err != nil { + t.Fatalf("newRekeyer: %v", err) + } + _, err = r.BeginRotation(ctx) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("BeginRotation(RSA-3072 on a TPM without it) code = %v, want FailedPrecondition (err=%v)", status.Code(err), err) + } + if !strings.Contains(err.Error(), "RSA-3072") { + t.Errorf("error %q does not name RSA-3072", err) + } + if _, ok, _ := s.RotationCSR(ctx); ok { + t.Error("BeginRotation staged a rotation despite the TPM lacking RSA-3072") + } +} diff --git a/internal/tpm/capabilities.go b/internal/tpm/capabilities.go index 12e7a46..84e5bc9 100644 --- a/internal/tpm/capabilities.go +++ b/internal/tpm/capabilities.go @@ -19,17 +19,27 @@ limitations under the License. */ import ( + "errors" "fmt" "github.com/google/go-tpm/tpm2" ) +// rsaProbeSizes are the RSA modulus sizes Probe asks the TPM about. The TPM +// 2.0 spec only obliges a part to implement RSA-2048; larger sizes are +// optional, so each is tested rather than assumed. +var rsaProbeSizes = []int{2048, 3072, 4096} + // Capabilities describes the subset of the TPM's reported capabilities // that PID 1 needs to check at boot. type Capabilities struct { // LoadedCurves is the set of ECC curves the TPM supports. A Phase 1 // Root cannot proceed unless this contains TPMECCNistP384. LoadedCurves []tpm2.TPMECCCurve + + // RSAKeyBits is the set of RSA signing-key sizes the TPM accepted in + // TPM2_TestParms. Empty when the TPM implements no RSA at all. + RSAKeyBits []int } // SupportsCurve reports whether the TPM advertised the given ECC curve. @@ -42,10 +52,22 @@ func (c Capabilities) SupportsCurve(curve tpm2.TPMECCCurve) bool { return false } -// Probe issues TPM2_GetCapability(TPM_CAP_ECC_CURVES) and returns the -// list of supported curves. Used at boot for fail-fast checks. +// SupportsRSAKeyBits reports whether the TPM accepted an RSA signing key of +// the given modulus size. +func (c Capabilities) SupportsRSAKeyBits(bits int) bool { + for _, supported := range c.RSAKeyBits { + if supported == bits { + return true + } + } + return false +} + +// Probe issues TPM2_GetCapability(TPM_CAP_ECC_CURVES) for the supported +// curves, and TPM2_TestParms for each RSA size in rsaProbeSizes. Used at boot +// for fail-fast checks and for logging what the part can do. // -// The query asks for up to 16 curves, which is more than the spec +// The curve query asks for up to 16 curves, which is more than the spec // defines, so a single call is sufficient on every real TPM. func (t *TPM) Probe() (Capabilities, error) { rwc, err := t.transport() @@ -69,5 +91,19 @@ func (t *TPM) Probe() (Capabilities, error) { out := make([]tpm2.TPMECCCurve, len(curves.ECCCurves)) copy(out, curves.ECCCurves) - return Capabilities{LoadedCurves: out}, nil + + var rsaBits []int + for _, bits := range rsaProbeSizes { + err := testParms(rwc, rsaTemplate(bits)) + var rc tpm2.TPMRC + switch { + case err == nil: + rsaBits = append(rsaBits, bits) + case errors.As(err, &rc): + // A TPM response code is the TPM saying no to this size. + default: + return Capabilities{}, fmt.Errorf("tpm: probe: TestParms(RSA-%d): %w", bits, err) + } + } + return Capabilities{LoadedCurves: out, RSAKeyBits: rsaBits}, nil } diff --git a/internal/tpm/doc.go b/internal/tpm/doc.go index a3149bf..c9cd369 100644 --- a/internal/tpm/doc.go +++ b/internal/tpm/doc.go @@ -2,14 +2,16 @@ // // - SRK provisioning under the Storage Hierarchy at persistent handle // 0x81000001. -// - Creation of ECDSA P-384 (Root) and P-256 (Issuing) signing keys -// bound to the SRK; the private blob never leaves the TPM in the -// clear. +// - Creation of ECDSA P-384 and RSA-3072/RSA-4096 CA signing keys bound +// to the SRK; the private blob never leaves the TPM in the clear. An +// RSA size the TPM does not implement is refused up front +// (ErrKeyAlgorithmUnsupported), never substituted. // - A crypto.Signer implementation that routes through TPM2_Sign and -// returns DER-encoded ECDSA signatures for crypto/x509.CreateCertificate -// to consume directly. -// - Capability probing (PT_LOADED_CURVES) so PID 1 can fail-fast when -// P-384 is unavailable on the target TPM. +// returns DER-encoded ECDSA signatures, or raw RSASSA-PKCS1-v1_5 / +// RSASSA-PSS signatures chosen by the caller's crypto.SignerOpts, for +// crypto/x509 to consume directly. +// - Capability probing (ECC curves, and RSA key sizes via TPM2_TestParms) +// so PID 1 can fail fast when P-384 is unavailable on the target TPM. // // go-tpm sits on the wire-format side of the project's stdlib-only-on- // the-crypto-path rule, not the crypto side: it marshals TPM2 command diff --git a/internal/tpm/key.go b/internal/tpm/key.go index d785caf..4a88262 100644 --- a/internal/tpm/key.go +++ b/internal/tpm/key.go @@ -19,8 +19,10 @@ limitations under the License. */ import ( + "crypto" "crypto/ecdsa" "crypto/elliptic" + "errors" "fmt" "math/big" @@ -32,23 +34,30 @@ import ( type KeyAlgorithm int const ( - // AlgorithmECDSAP384 pairs ECDSA on NIST P-384 with SHA-384. It is the - // only algorithm this package can create inside a TPM. + // AlgorithmECDSAP384 pairs ECDSA on NIST P-384 with SHA-384. AlgorithmECDSAP384 KeyAlgorithm = iota + 1 // AlgorithmRSA2048, AlgorithmRSA3072 and AlgorithmRSA4096 are RSA CA keys - // at the named modulus size. They exist for the software key backend, - // which holds the CA key in the encrypted state partition rather than in - // hardware. This package rejects them: every layer of the TPM path is - // written to ECC (the key template, the public-area parser, and the - // signer's scheme and signature encoding), and in practice most TPM 2.0 - // parts implement only RSA-2048. Creating a TPM-resident RSA key is - // tracked separately rather than half-supported here. + // at the named modulus size. RSA-3072 and RSA-4096 can be created inside + // the TPM when the part implements that size; CreateKey checks first and + // fails with ErrKeyAlgorithmUnsupported rather than creating anything + // else. RSA-2048 is below MinRSAKeyBits and is refused here; it remains + // only for the software key backend. AlgorithmRSA2048 AlgorithmRSA3072 AlgorithmRSA4096 ) +// MinRSAKeyBits is the smallest RSA key CreateKey will make in the TPM. It +// matches the 3072-bit floor the CA enforces on every subject key it +// certifies, because a CA key is itself the subject of its own certificate. +const MinRSAKeyBits = 3072 + +// ErrKeyAlgorithmUnsupported is returned by CreateKey when the TPM does not +// implement the requested algorithm or key size. Many TPM 2.0 parts implement +// only RSA-2048, so this is the expected outcome for RSA-3072 on them. +var ErrKeyAlgorithmUnsupported = errors.New("tpm: key algorithm not supported by this TPM") + // RSAKeyBits returns the modulus size for an RSA KeyAlgorithm, and false when // alg is not an RSA algorithm. func RSAKeyBits(alg KeyAlgorithm) (int, bool) { @@ -73,7 +82,7 @@ type Key struct { tpm *TPM handle tpm2.TPMHandle name tpm2.TPM2BName - pub *ecdsa.PublicKey + pub crypto.PublicKey alg KeyAlgorithm } @@ -99,6 +108,11 @@ type CreatedKey struct { // Private/Public (typically to the encrypted state partition) and later // restore them with LoadKey. // +// For an RSA algorithm the TPM is first asked, with TPM2_TestParms, whether +// it implements that modulus size. If it does not, CreateKey returns +// ErrKeyAlgorithmUnsupported and creates nothing; it never falls back to a +// smaller size or to a software key. +// // ProvisionSRK must have run successfully (in this boot or a previous // one) before calling CreateKey. func (t *TPM) CreateKey(alg KeyAlgorithm) (*CreatedKey, error) { @@ -110,6 +124,22 @@ func (t *TPM) CreateKey(alg KeyAlgorithm) (*CreatedKey, error) { if err != nil { return nil, err } + if _, isRSA := RSAKeyBits(alg); isRSA { + if err := testParms(rwc, template); err != nil { + return nil, fmt.Errorf("%w: %s: %v", ErrKeyAlgorithmUnsupported, algName(alg), err) + } + } + return t.createFromTemplate(template) +} + +// createFromTemplate creates a key from template under the SRK. CreateKey is +// the only production caller; tests also use it to exercise the RSA path at a +// size the in-process simulator implements. +func (t *TPM) createFromTemplate(template tpm2.TPMTPublic) (*CreatedKey, error) { + rwc, err := t.transport() + if err != nil { + return nil, err + } srkName, err := readSRKName(rwc) if err != nil { return nil, err @@ -137,8 +167,21 @@ func (t *TPM) CreateKey(alg KeyAlgorithm) (*CreatedKey, error) { // LoadKey loads a previously-created signing key into the TPM as a // transient object under the SRK and returns a Key implementing -// crypto.Signer. The caller is responsible for Close(). +// crypto.Signer. An RSA key below MinRSAKeyBits is refused. The caller is +// responsible for Close(). func (t *TPM) LoadKey(private, public []byte) (*Key, error) { + key, err := t.loadKey(private, public) + if err != nil { + return nil, err + } + if bits, isRSA := RSAKeyBits(key.alg); isRSA && bits < MinRSAKeyBits { + _ = key.Close() + return nil, fmt.Errorf("tpm: LoadKey: RSA-%d is below the %d-bit floor for a CA key", bits, MinRSAKeyBits) + } + return key, nil +} + +func (t *TPM) loadKey(private, public []byte) (*Key, error) { rwc, err := t.transport() if err != nil { return nil, err @@ -176,7 +219,7 @@ func (t *TPM) LoadKey(private, public []byte) (*Key, error) { _, _ = (tpm2.FlushContext{FlushHandle: loaded.ObjectHandle}.Execute(rwc)) return nil, fmt.Errorf("tpm: LoadKey: public contents: %w", err) } - ecdsaPub, alg, err := parseECDSAPublic(publicTemplate) + parsedPub, alg, err := parsePublic(publicTemplate) if err != nil { _, _ = (tpm2.FlushContext{FlushHandle: loaded.ObjectHandle}.Execute(rwc)) return nil, err @@ -186,7 +229,7 @@ func (t *TPM) LoadKey(private, public []byte) (*Key, error) { tpm: t, handle: loaded.ObjectHandle, name: loaded.Name, - pub: ecdsaPub, + pub: parsedPub, alg: alg, }, nil } @@ -204,26 +247,33 @@ func (k *Key) Close() error { return nil } -// publicTemplate builds the TPMTPublic template for the requested -// algorithm. ECDSA P-384 is the only algorithm a TPM-resident key can use -// here; the RSA algorithms are rejected with a message naming the reason. +// keyAttributes are the object attributes of every signing key this package +// creates: generated inside the TPM (sensitiveDataOrigin), never duplicable off +// it (fixedTPM) or re-parented (fixedParent), and usable with a password auth +// session. +var keyAttributes = tpm2.TPMAObject{ + SignEncrypt: true, + FixedTPM: true, + FixedParent: true, + SensitiveDataOrigin: true, + UserWithAuth: true, +} + +// publicTemplate builds the TPMTPublic template for the requested algorithm. func publicTemplate(alg KeyAlgorithm) (tpm2.TPMTPublic, error) { if bits, isRSA := RSAKeyBits(alg); isRSA { - return tpm2.TPMTPublic{}, fmt.Errorf("tpm: RSA-%d CA keys cannot be created in the TPM; use a software-backed state key mode for an RSA CA", bits) + if bits < MinRSAKeyBits { + return tpm2.TPMTPublic{}, fmt.Errorf("tpm: RSA-%d is below the %d-bit floor for a CA key", bits, MinRSAKeyBits) + } + return rsaTemplate(bits), nil } if alg != AlgorithmECDSAP384 { return tpm2.TPMTPublic{}, fmt.Errorf("tpm: unsupported KeyAlgorithm %d", alg) } return tpm2.TPMTPublic{ - Type: tpm2.TPMAlgECC, - NameAlg: tpm2.TPMAlgSHA256, - ObjectAttributes: tpm2.TPMAObject{ - SignEncrypt: true, - FixedTPM: true, - FixedParent: true, - SensitiveDataOrigin: true, - UserWithAuth: true, - }, + Type: tpm2.TPMAlgECC, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: keyAttributes, Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgECC, &tpm2.TPMSECCParms{ Symmetric: tpm2.TPMTSymDefObject{Algorithm: tpm2.TPMAlgNull}, Scheme: tpm2.TPMTECCScheme{ @@ -237,9 +287,93 @@ func publicTemplate(alg KeyAlgorithm) (tpm2.TPMTPublic, error) { }, nil } +// rsaTemplate is the public template of an RSA signing key of the given size. +func rsaTemplate(bits int) tpm2.TPMTPublic { + return tpm2.TPMTPublic{ + Type: tpm2.TPMAlgRSA, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: keyAttributes, + Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgRSA, rsaParms(bits)), + } +} + +// rsaParms are the RSA key parameters for a signing key of the given size. +// The scheme is left null so the key carries no fixed padding or hash: each +// Sign call names both from the caller's crypto.SignerOpts. A zero exponent is +// the TPM's encoding of the default, 65537. +func rsaParms(bits int) *tpm2.TPMSRSAParms { + return &tpm2.TPMSRSAParms{ + Symmetric: tpm2.TPMTSymDefObject{Algorithm: tpm2.TPMAlgNull}, + Scheme: tpm2.TPMTRSAScheme{Scheme: tpm2.TPMAlgNull}, + KeyBits: tpm2.TPMIRSAKeyBits(bits), + } +} + +// testParms asks the TPM, with TPM2_TestParms, whether it implements the +// algorithm and parameters of template. It creates nothing. +func testParms(rwc transport.TPM, template tpm2.TPMTPublic) error { + _, err := (tpm2.TestParms{ + Parameters: tpm2.TPMTPublicParms{Type: template.Type, Parameters: template.Parameters}, + }).Execute(rwc) + return err +} + +// algName is the machine-config spelling of alg, for error messages. +func algName(alg KeyAlgorithm) string { + if bits, isRSA := RSAKeyBits(alg); isRSA { + return fmt.Sprintf("RSA-%d", bits) + } + if alg == AlgorithmECDSAP384 { + return "ECDSA-P384" + } + return fmt.Sprintf("KeyAlgorithm(%d)", alg) +} + +// parsePublic extracts the crypto public key and KeyAlgorithm from the +// TPM-returned public area of a loaded key. +func parsePublic(t *tpm2.TPMTPublic) (crypto.PublicKey, KeyAlgorithm, error) { + switch t.Type { + case tpm2.TPMAlgECC: + return parseECDSAPublic(t) + case tpm2.TPMAlgRSA: + return parseRSAPublic(t) + default: + return nil, 0, fmt.Errorf("tpm: parsePublic: unsupported key type 0x%x", t.Type) + } +} + +// parseRSAPublic extracts a crypto/rsa.PublicKey from the TPM-returned public +// area. It maps every size a KeyAlgorithm names; LoadKey applies the floor. +func parseRSAPublic(t *tpm2.TPMTPublic) (crypto.PublicKey, KeyAlgorithm, error) { + parms, err := t.Parameters.RSADetail() + if err != nil { + return nil, 0, fmt.Errorf("tpm: parseRSAPublic: RSA parameters: %w", err) + } + var alg KeyAlgorithm + switch parms.KeyBits { + case 2048: + alg = AlgorithmRSA2048 + case 3072: + alg = AlgorithmRSA3072 + case 4096: + alg = AlgorithmRSA4096 + default: + return nil, 0, fmt.Errorf("tpm: parseRSAPublic: unexpected RSA key size %d", parms.KeyBits) + } + modulus, err := t.Unique.RSA() + if err != nil { + return nil, 0, fmt.Errorf("tpm: parseRSAPublic: RSA modulus: %w", err) + } + pub, err := tpm2.RSAPub(parms, modulus) + if err != nil { + return nil, 0, fmt.Errorf("tpm: parseRSAPublic: %w", err) + } + return pub, alg, nil +} + // parseECDSAPublic extracts a crypto/ecdsa.PublicKey from the TPM-returned // public template. -func parseECDSAPublic(t *tpm2.TPMTPublic) (*ecdsa.PublicKey, KeyAlgorithm, error) { +func parseECDSAPublic(t *tpm2.TPMTPublic) (crypto.PublicKey, KeyAlgorithm, error) { if t.Type != tpm2.TPMAlgECC { return nil, 0, fmt.Errorf("tpm: parseECDSAPublic: not an ECC key (type=0x%x)", t.Type) } diff --git a/internal/tpm/keyalg_test.go b/internal/tpm/keyalg_test.go index 013a268..fe21ebf 100644 --- a/internal/tpm/keyalg_test.go +++ b/internal/tpm/keyalg_test.go @@ -21,6 +21,8 @@ limitations under the License. import ( "strings" "testing" + + "github.com/google/go-tpm/tpm2" ) func TestRSAKeyBits(t *testing.T) { @@ -43,16 +45,23 @@ func TestRSAKeyBits(t *testing.T) { } } -// TestPublicTemplateRejectsRSA pins the boundary of RSA CA support: the -// software key backend holds RSA CA keys, this package does not. The error has -// to name the reason, because the alternative failure mode is an operator -// setting an RSA algorithm on a TPM-backed node and getting an opaque refusal. -func TestPublicTemplateRejectsRSA(t *testing.T) { - for _, alg := range []KeyAlgorithm{AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096} { - if _, err := publicTemplate(alg); err == nil { - t.Errorf("publicTemplate(%d): want error, got nil", alg) - } else if !strings.Contains(err.Error(), "RSA") { - t.Errorf("publicTemplate(%d) error = %q, want it to mention RSA", alg, err) +// TestPublicTemplateRSAFloor pins the boundary of TPM-held RSA CA keys: RSA-3072 +// and RSA-4096 get a template, RSA-2048 is refused because a CA key below the +// 3072-bit subject-key floor could not certify a key of its own size. The error +// has to name the reason, because the alternative failure mode is an operator +// setting RSA-2048 on a TPM-backed node and getting an opaque refusal. +func TestPublicTemplateRSAFloor(t *testing.T) { + if _, err := publicTemplate(AlgorithmRSA2048); err == nil { + t.Error("publicTemplate(AlgorithmRSA2048): want error, got nil") + } else if !strings.Contains(err.Error(), "3072") { + t.Errorf("publicTemplate(AlgorithmRSA2048) error = %q, want it to name the 3072-bit floor", err) + } + for _, alg := range []KeyAlgorithm{AlgorithmRSA3072, AlgorithmRSA4096} { + tmpl, err := publicTemplate(alg) + if err != nil { + t.Errorf("publicTemplate(%d): unexpected error: %v", alg, err) + } else if tmpl.Type != tpm2.TPMAlgRSA { + t.Errorf("publicTemplate(%d).Type = 0x%x, want RSA", alg, tmpl.Type) } } if _, err := publicTemplate(AlgorithmECDSAP384); err != nil { diff --git a/internal/tpm/rsa_test.go b/internal/tpm/rsa_test.go new file mode 100644 index 0000000..0b449c3 --- /dev/null +++ b/internal/tpm/rsa_test.go @@ -0,0 +1,287 @@ +package tpm + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "errors" + "strings" + "testing" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// The in-process simulator (go-tpm-tools' build of the Microsoft reference +// TPM) implements RSA-2048 only, which is also what many real TPM parts +// implement. So the simulator covers two things here: the RSA signing +// mechanics, exercised on a 2048-bit key made through the same template and +// load path CreateKey uses, and the refusal of RSA-3072 on a TPM that lacks +// it. A TPM-held RSA-3072 key end to end is covered against swtpm in +// internal/e2e. + +// newSimRSAKey creates and loads an RSA-2048 key in the simulator through the +// unexported path below CreateKey's floor. +func newSimRSAKey(t *testing.T, tp *TPM) *Key { + t.Helper() + ck, err := tp.createFromTemplate(rsaTemplate(2048)) + if err != nil { + t.Fatalf("createFromTemplate(RSA-2048): %v", err) + } + key, err := tp.loadKey(ck.Private, ck.Public) + if err != nil { + t.Fatalf("loadKey(RSA-2048): %v", err) + } + t.Cleanup(func() { _ = key.Close() }) + return key +} + +// countingTPM forwards every command to the simulator and counts the +// TPM2_Create commands it sees. +type countingTPM struct { + transport.TPMCloser + creates int +} + +func (c *countingTPM) Send(cmd []byte) ([]byte, error) { + if len(cmd) >= 10 && tpm2.TPMCC(binary.BigEndian.Uint32(cmd[6:10])) == tpm2.TPMCCCreate { + c.creates++ + } + return c.TPMCloser.Send(cmd) +} + +func TestProbe_ReportsRSAKeySizes(t *testing.T) { + tp := openSim(t) + caps, err := tp.Probe() + if err != nil { + t.Fatalf("Probe: %v", err) + } + if !caps.SupportsRSAKeyBits(2048) { + t.Fatalf("simulator implements RSA-2048; Probe reported %v", caps.RSAKeyBits) + } + if caps.SupportsRSAKeyBits(3072) { + t.Fatalf("simulator does not implement RSA-3072; Probe reported %v", caps.RSAKeyBits) + } + if !caps.SupportsCurve(tpm2.TPMECCNistP384) { + t.Fatalf("adding RSA probing must not drop the curve list; got %v", caps.LoadedCurves) + } +} + +// TestCreateKey_UnsupportedRSASizeFailsClearly covers a TPM without the +// configured RSA size. CreateKey must refuse with ErrKeyAlgorithmUnsupported, +// name the size, and send no TPM2_Create: there is no fallback to a smaller +// size or to a software key. +func TestCreateKey_UnsupportedRSASizeFailsClearly(t *testing.T) { + sim := openSim(t) + spy := &countingTPM{TPMCloser: sim.rwc} + tp := &TPM{rwc: spy} + + for _, alg := range []KeyAlgorithm{AlgorithmRSA3072, AlgorithmRSA4096} { + ck, err := tp.CreateKey(alg) + if !errors.Is(err, ErrKeyAlgorithmUnsupported) { + t.Fatalf("CreateKey(%s) = %v, want ErrKeyAlgorithmUnsupported", algName(alg), err) + } + if ck != nil { + t.Fatalf("CreateKey(%s) returned key material alongside the error", algName(alg)) + } + if !strings.Contains(err.Error(), algName(alg)) { + t.Errorf("CreateKey(%s) error %q does not name the algorithm", algName(alg), err) + } + } + if spy.creates != 0 { + t.Fatalf("TPM2_Create was sent %d times; the size check must refuse first", spy.creates) + } + + if _, err := tp.CreateKey(AlgorithmECDSAP384); err != nil { + t.Fatalf("CreateKey(ECDSA-P384) on the same TPM: %v", err) + } +} + +func TestLoadKey_RefusesRSABelowFloor(t *testing.T) { + tp := openSim(t) + ck, err := tp.createFromTemplate(rsaTemplate(2048)) + if err != nil { + t.Fatalf("createFromTemplate: %v", err) + } + if _, err := tp.LoadKey(ck.Private, ck.Public); err == nil { + t.Fatal("LoadKey(RSA-2048): want an error, the CA key floor is 3072") + } +} + +func TestSimRSAKey_PublicKey(t *testing.T) { + tp := openSim(t) + key := newSimRSAKey(t, tp) + pk, ok := key.Public().(*rsa.PublicKey) + if !ok { + t.Fatalf("Public() = %T, want *rsa.PublicKey", key.Public()) + } + if pk.N.BitLen() != 2048 || pk.E != 65537 { + t.Fatalf("public key = %d bits, e=%d; want 2048 bits, e=65537", pk.N.BitLen(), pk.E) + } +} + +// TestRSATemplate_HasECDSAKeyProperties pins the property that makes a +// TPM-held CA key worth having: the private half is generated inside the TPM +// and can never be moved off it or re-parented, exactly as for the ECDSA key. +func TestRSATemplate_HasECDSAKeyProperties(t *testing.T) { + tp := openSim(t) + attrsOf := func(ck *CreatedKey) tpm2.TPMAObject { + pub, err := tpm2.Unmarshal[tpm2.TPM2BPublic](ck.Public) + if err != nil { + t.Fatalf("unmarshal public: %v", err) + } + contents, err := pub.Contents() + if err != nil { + t.Fatalf("public contents: %v", err) + } + return contents.ObjectAttributes + } + rsaCK, err := tp.createFromTemplate(rsaTemplate(2048)) + if err != nil { + t.Fatalf("createFromTemplate(RSA): %v", err) + } + ecCK, err := tp.CreateKey(AlgorithmECDSAP384) + if err != nil { + t.Fatalf("CreateKey(ECDSA): %v", err) + } + rsaAttrs, ecAttrs := attrsOf(rsaCK), attrsOf(ecCK) + if rsaAttrs != ecAttrs { + t.Fatalf("RSA key attributes %+v differ from the ECDSA key's %+v", rsaAttrs, ecAttrs) + } + if !rsaAttrs.FixedTPM || !rsaAttrs.FixedParent || !rsaAttrs.SensitiveDataOrigin { + t.Fatalf("RSA key attributes %+v: want fixedTPM, fixedParent and sensitiveDataOrigin", rsaAttrs) + } + for _, alg := range []KeyAlgorithm{AlgorithmRSA3072, AlgorithmRSA4096} { + tmpl, err := publicTemplate(alg) + if err != nil { + t.Fatalf("publicTemplate(%s): %v", algName(alg), err) + } + if tmpl.ObjectAttributes != ecAttrs { + t.Fatalf("publicTemplate(%s) attributes %+v differ from the ECDSA key's %+v", algName(alg), tmpl.ObjectAttributes, ecAttrs) + } + } +} + +func TestSign_RSA_PKCS1v15(t *testing.T) { + tp := openSim(t) + key := newSimRSAKey(t, tp) + pk := key.Public().(*rsa.PublicKey) + msg := []byte("CryptOS-PKI TPM RSA round trip") + sum256 := sha256.Sum256(msg) + sum384 := sha512.Sum384(msg) + sum512 := sha512.Sum512(msg) + + for _, tc := range []struct { + name string + hash crypto.Hash + digest []byte + }{ + {"SHA-256", crypto.SHA256, sum256[:]}, + {"SHA-384", crypto.SHA384, sum384[:]}, + {"SHA-512", crypto.SHA512, sum512[:]}, + } { + t.Run(tc.name, func(t *testing.T) { + sig, err := key.Sign(rand.Reader, tc.digest, tc.hash) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if len(sig) != pk.Size() { + t.Fatalf("signature is %d bytes, want the modulus size %d (raw, not ASN.1-wrapped)", len(sig), pk.Size()) + } + if err := rsa.VerifyPKCS1v15(pk, tc.hash, tc.digest, sig); err != nil { + t.Fatalf("VerifyPKCS1v15: %v", err) + } + }) + } +} + +// TestSign_RSA_HonoursPSSOpts checks that the scheme comes from the caller's +// opts: a PSS request must yield a PSS signature, not the PKCS#1 v1.5 one a +// scheme baked into the key would give. +func TestSign_RSA_HonoursPSSOpts(t *testing.T) { + tp := openSim(t) + key := newSimRSAKey(t, tp) + pk := key.Public().(*rsa.PublicKey) + digest := sha256.Sum256([]byte("pss")) + + opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto, Hash: crypto.SHA256} + sig, err := key.Sign(rand.Reader, digest[:], opts) + if err != nil { + t.Fatalf("Sign(PSS): %v", err) + } + if err := rsa.VerifyPSS(pk, crypto.SHA256, digest[:], sig, opts); err != nil { + t.Fatalf("VerifyPSS: %v", err) + } + if rsa.VerifyPKCS1v15(pk, crypto.SHA256, digest[:], sig) == nil { + t.Fatal("a PSS request produced a PKCS#1 v1.5 signature") + } + + // The TPM picks its own PSS salt length. When the caller pins one, the + // signer either returns a signature valid under that length or refuses; + // it never returns one the caller's verifier would reject. + pinned := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256} + if sig, err := key.Sign(rand.Reader, digest[:], pinned); err == nil { + if err := rsa.VerifyPSS(pk, crypto.SHA256, digest[:], sig, pinned); err != nil { + t.Fatalf("Sign returned a PSS signature invalid under the requested salt length: %v", err) + } + } +} + +func TestSign_RSA_RejectsBadInput(t *testing.T) { + tp := openSim(t) + key := newSimRSAKey(t, tp) + sum384 := sha512.Sum384([]byte("x")) + + if _, err := key.Sign(rand.Reader, sum384[:], nil); err == nil { + t.Error("Sign with nil opts: want an error, RSA needs the hash and padding from opts") + } + if _, err := key.Sign(rand.Reader, sum384[:], crypto.SHA256); err == nil { + t.Error("Sign with a 48-byte digest and SHA-256 opts: want a length error") + } + if _, err := key.Sign(rand.Reader, make([]byte, 20), crypto.SHA1); err == nil { + t.Error("Sign with SHA-1: want an error, only SHA-2 is accepted") + } +} + +func TestSign_ECDSAIgnoresOpts(t *testing.T) { + tp := openSim(t) + ck, err := tp.CreateKey(AlgorithmECDSAP384) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + key, err := tp.LoadKey(ck.Private, ck.Public) + if err != nil { + t.Fatalf("LoadKey: %v", err) + } + defer func() { _ = key.Close() }() + sum := sha512.Sum384([]byte("ecdsa with opts")) + sig, err := key.Sign(rand.Reader, sum[:], crypto.SHA384) + if err != nil { + t.Fatalf("Sign(ECDSA, SHA384 opts): %v", err) + } + if !ecdsa.VerifyASN1(key.Public().(*ecdsa.PublicKey), sum[:], sig) { + t.Fatal("ecdsa.VerifyASN1 rejected a TPM-signed signature") + } +} diff --git a/internal/tpm/signer.go b/internal/tpm/signer.go index 31e1aa6..9c9c6f2 100644 --- a/internal/tpm/signer.go +++ b/internal/tpm/signer.go @@ -20,6 +20,7 @@ limitations under the License. import ( "crypto" + "crypto/rsa" "encoding/asn1" "fmt" "io" @@ -28,8 +29,8 @@ import ( "github.com/google/go-tpm/tpm2" ) -// Public satisfies crypto.Signer. Returns the loaded key's ECDSA public -// key (with curve set to elliptic.P384 for AlgorithmECDSAP384). +// Public satisfies crypto.Signer. It returns *ecdsa.PublicKey (curve P-384) +// for an AlgorithmECDSAP384 key and *rsa.PublicKey for an RSA key. func (k *Key) Public() crypto.PublicKey { if k == nil { return nil @@ -37,31 +38,117 @@ func (k *Key) Public() crypto.PublicKey { return k.pub } -// Sign satisfies crypto.Signer. The digest must be SHA-384 for an -// AlgorithmECDSAP384 key (48 bytes); shorter or longer inputs are -// rejected. +// Sign satisfies crypto.Signer, delegating the private-key operation to +// TPM2_Sign. // -// The rand argument is required by the interface but unused — randomness -// for ECDSA signing is sourced from the TPM itself. +// For an AlgorithmECDSAP384 key the digest must be SHA-384 (48 bytes) and opts +// is not consulted; the result is a DER-encoded SEQUENCE { r INTEGER, +// s INTEGER }, the form crypto/x509.CreateCertificate stores. // -// Returns a DER-encoded SEQUENCE { r INTEGER, s INTEGER } suitable for -// embedding directly into an X.509 certificate signature field — i.e. -// the form crypto/x509.CreateCertificate stores. -func (k *Key) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { +// For an RSA key opts is required: its hash (SHA-256, SHA-384 or SHA-512) must +// match the digest length, and *rsa.PSSOptions selects RSASSA-PSS where any +// other opts selects RSASSA-PKCS1-v1_5. The TPM applies the padding. The raw +// signature is returned, and only after it verifies against the public key, so +// a faulty signature is never released. +// +// The rand argument is required by the interface but unused: the TPM sources +// its own randomness. +func (k *Key) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { if k == nil || k.tpm == nil { return nil, ErrClosed } - if k.alg != AlgorithmECDSAP384 { - return nil, fmt.Errorf("tpm: Sign: unsupported KeyAlgorithm %d", k.alg) + switch pub := k.pub.(type) { + case *rsa.PublicKey: + return k.signRSA(pub, digest, opts) + default: + if k.alg != AlgorithmECDSAP384 { + return nil, fmt.Errorf("tpm: Sign: unsupported KeyAlgorithm %d", k.alg) + } + return k.signECDSA(digest) } +} + +func (k *Key) signECDSA(digest []byte) ([]byte, error) { if len(digest) != 48 { return nil, fmt.Errorf("tpm: Sign: digest must be 48 bytes (SHA-384) for P-384, got %d", len(digest)) } - rwc, err := k.tpm.transport() + resp, err := k.tpmSign(digest, tpm2.TPMAlgECDSA, tpm2.TPMAlgSHA384) + if err != nil { + return nil, err + } + + ecdsaSig, err := resp.Signature.Signature.ECDSA() + if err != nil { + return nil, fmt.Errorf("tpm: Sign: extract ECDSA signature: %w", err) + } + + r := new(big.Int).SetBytes(ecdsaSig.SignatureR.Buffer) + s := new(big.Int).SetBytes(ecdsaSig.SignatureS.Buffer) + + // DER-encode SEQUENCE { r INTEGER, s INTEGER } — the form + // crypto/x509.CreateCertificate expects from a crypto.Signer. + der, err := asn1.Marshal(ecdsaSignature{R: r, S: s}) + if err != nil { + return nil, fmt.Errorf("tpm: Sign: marshal signature: %w", err) + } + return der, nil +} + +func (k *Key) signRSA(pub *rsa.PublicKey, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + if opts == nil { + return nil, fmt.Errorf("tpm: Sign: an RSA key needs crypto.SignerOpts to name the hash and padding") + } + hash := opts.HashFunc() + hashAlg, err := tpmHashAlg(hash) + if err != nil { + return nil, err + } + if len(digest) != hash.Size() { + return nil, fmt.Errorf("tpm: Sign: digest is %d bytes, want %d for %v", len(digest), hash.Size(), hash) + } + + pss, isPSS := opts.(*rsa.PSSOptions) + scheme := tpm2.TPMAlgRSASSA + if isPSS { + scheme = tpm2.TPMAlgRSAPSS + } + resp, err := k.tpmSign(digest, scheme, hashAlg) if err != nil { return nil, err } + var rsaSig *tpm2.TPMSSignatureRSA + if isPSS { + rsaSig, err = resp.Signature.Signature.RSAPSS() + } else { + rsaSig, err = resp.Signature.Signature.RSASSA() + } + if err != nil { + return nil, fmt.Errorf("tpm: Sign: extract RSA signature: %w", err) + } + sig := rsaSig.Sig.Buffer + + // The TPM chooses its own PSS salt length, so a caller that pinned a + // different one is refused here rather than handed a signature its own + // verifier rejects. The same check keeps a faulty signature from ever + // leaving the signer. + if isPSS { + err = rsa.VerifyPSS(pub, hash, digest, sig, pss) + } else { + err = rsa.VerifyPKCS1v15(pub, hash, digest, sig) + } + if err != nil { + return nil, fmt.Errorf("tpm: Sign: TPM signature does not verify under the requested scheme: %w", err) + } + return sig, nil +} + +// tpmSign runs TPM2_Sign over an externally computed digest. +func (k *Key) tpmSign(digest []byte, scheme, hashAlg tpm2.TPMAlgID) (*tpm2.SignResponse, error) { + rwc, err := k.tpm.transport() + if err != nil { + return nil, err + } resp, err := (tpm2.Sign{ KeyHandle: tpm2.AuthHandle{ Handle: k.handle, @@ -70,10 +157,8 @@ func (k *Key) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, err }, Digest: tpm2.TPM2BDigest{Buffer: digest}, InScheme: tpm2.TPMTSigScheme{ - Scheme: tpm2.TPMAlgECDSA, - Details: tpm2.NewTPMUSigScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSchemeHash{ - HashAlg: tpm2.TPMAlgSHA384, - }), + Scheme: scheme, + Details: tpm2.NewTPMUSigScheme(scheme, &tpm2.TPMSSchemeHash{HashAlg: hashAlg}), }, // Empty hash-check ticket; this key is not restricted so the TPM // accepts an externally-computed digest. @@ -85,22 +170,22 @@ func (k *Key) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, err if err != nil { return nil, fmt.Errorf("tpm: Sign: TPM2_Sign: %w", err) } + return resp, nil +} - ecdsaSig, err := resp.Signature.Signature.ECDSA() - if err != nil { - return nil, fmt.Errorf("tpm: Sign: extract ECDSA signature: %w", err) - } - - r := new(big.Int).SetBytes(ecdsaSig.SignatureR.Buffer) - s := new(big.Int).SetBytes(ecdsaSig.SignatureS.Buffer) - - // DER-encode SEQUENCE { r INTEGER, s INTEGER } — the form - // crypto/x509.CreateCertificate expects from a crypto.Signer. - der, err := asn1.Marshal(ecdsaSignature{R: r, S: s}) - if err != nil { - return nil, fmt.Errorf("tpm: Sign: marshal signature: %w", err) +// tpmHashAlg maps a SHA-2 crypto.Hash to its TPM algorithm ID. Anything else +// is refused: a CA signature over SHA-1 is not acceptable. +func tpmHashAlg(h crypto.Hash) (tpm2.TPMAlgID, error) { + switch h { + case crypto.SHA256: + return tpm2.TPMAlgSHA256, nil + case crypto.SHA384: + return tpm2.TPMAlgSHA384, nil + case crypto.SHA512: + return tpm2.TPMAlgSHA512, nil + default: + return 0, fmt.Errorf("tpm: Sign: unsupported hash %v for an RSA key (want SHA-256, SHA-384 or SHA-512)", h) } - return der, nil } // ecdsaSignature is the ASN.1 SEQUENCE encoding of an ECDSA signature. diff --git a/internal/tpm/tpm.go b/internal/tpm/tpm.go index 25cd0bb..0615f84 100644 --- a/internal/tpm/tpm.go +++ b/internal/tpm/tpm.go @@ -68,6 +68,13 @@ func OpenSimulator() (*TPM, error) { return &TPM{rwc: transport.FromReadWriteCloser(sim)}, nil } +// OpenTransport returns a TPM over an already-open transport, such as a +// socket to an swtpm instance in tests. The TPM takes ownership of rwc and +// closes it on Close. +func OpenTransport(rwc transport.TPMCloser) *TPM { + return &TPM{rwc: rwc} +} + // Close releases the underlying TPM connection. Persistent objects // (e.g. the SRK at SRKPersistentHandle) remain in the TPM across opens. func (t *TPM) Close() error { From 50b5fe681cb1ae9ef2c003c6741fcca962cc3d08 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:42:44 -0400 Subject: [PATCH 2/2] 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> --- internal/e2e/tpm_rsa_e2e_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/e2e/tpm_rsa_e2e_test.go b/internal/e2e/tpm_rsa_e2e_test.go index b97ab07..d6df2ff 100644 --- a/internal/e2e/tpm_rsa_e2e_test.go +++ b/internal/e2e/tpm_rsa_e2e_test.go @@ -69,9 +69,38 @@ import ( // swtpmSocket is a transport.TPMCloser over swtpm's raw command socket: one // command out, one length-prefixed response back. +// +// On a node, /dev/tpmrm0 goes through the Linux TPM driver, which resends a +// command the TPM answered with TPM_RC_RETRY or TPM_RC_TESTING. A raw socket has +// no driver, so Send does the same. Some libtpms versions answer an RSA +// signature with TPM_RC_RETRY. type swtpmSocket struct{ conn net.Conn } +// Response codes the Linux TPM driver retries (TPM 2.0 Part 2, 6.6.3). +const ( + rcRetry = 0x922 + rcTesting = 0x90A +) + func (s *swtpmSocket) Send(cmd []byte) ([]byte, error) { + deadline := time.Now().Add(10 * time.Second) + for { + rsp, err := s.roundTrip(cmd) + if err != nil { + return nil, err + } + switch binary.BigEndian.Uint32(rsp[6:10]) { + case rcRetry, rcTesting: + if time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + continue + } + } + return rsp, nil + } +} + +func (s *swtpmSocket) roundTrip(cmd []byte) ([]byte, error) { if _, err := s.conn.Write(cmd); err != nil { return nil, err }