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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions docs/vmca-subordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions internal/ceremony/ceremony.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"errors"
"fmt"
"io"
"log"
"sync"
"time"

Expand Down Expand Up @@ -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)
}
Expand Down
68 changes: 68 additions & 0 deletions internal/ceremony/rsa_tpm_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading