Skip to content

Commit cf71c6e

Browse files
authored
fix(init): read the state-key mode from the volume after the first boot (#305)
* fix(init): read the state-key mode from the volume after the first boot A node whose state_key.mode came from its machine config and differed from the image's build-time default could not come back from any reboot. init resolved the mode before unlock from the ESP config stage only, and the stage exists only on the first boot, so every later boot fell back to the build-time mode and opened the volume with the wrong protector. Later boots now derive the mode from the LUKS2 header the volume was sealed with: a cryptos-tpm2 token means tpm, a cryptos-kms token means kms (with the endpoint and trust it carries), and no protector token means nodeid. The first boot still takes it from the staged config, else the build-time default. The multi-token TPM header left by an upgrade still reads as tpm. The KMS token carried no type or keyslots, which LUKS2 requires, so cryptsetup refused to import it and kms mode could not finish a first boot. It now has type cryptos-kms and the state keyslot. The mode is fixed at install. ApplyConfig on a running node and the re-provision installer refuse a config naming another state_key.mode with FailedPrecondition, and a boot whose persisted config disagrees logs it and keeps the sealed mode. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * docs: the state-key mode is fixed at install and survives upgrades Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * style: gofmt the new state-key files 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 95847f3 commit cf71c6e

14 files changed

Lines changed: 778 additions & 46 deletions

‎build/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,21 @@ stored on the encrypted state partition. `cryptosctl status` reports
172172
> the Root key. Use `nodeid` to run CryptOS where a vTPM is unavailable, never for
173173
> a CA guarding real trust.
174174
175+
`STATEKEY` is only the image's default. A machine config can pick another mode
176+
with `state_key.mode` (`tpm`, `nodeid` or `kms`), and that choice is read from
177+
the config staged for the first boot, when the state partition is formatted.
178+
From then on the mode is fixed: every later boot, including one into an
179+
upgraded image built with a different `STATEKEY`, reads it from the state
180+
partition's LUKS2 header (a `cryptos-tpm2` token means `tpm`, a `cryptos-kms`
181+
token means `kms`, no protector token means `nodeid`), not from the image or
182+
the config.
183+
184+
> [!CAUTION]
185+
> The state-key mode cannot be changed on an installed node. `config apply` of a
186+
> config naming a different `state_key.mode` is refused with
187+
> `FailedPrecondition` and nothing is written. To change the mode, reinstall
188+
> the node.
189+
175190
## Machine config delivery
176191

177192
The image is **config-free**: the rootfs carries no `machine.yaml`. Machine

‎docs/image-upgrade.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,18 @@ poor trade against PKCS#1 v1.5 over a SHA-256 digest.
192192
- **Only one previous image is retained.** Two upgrades in a row leave you able
193193
to roll back one.
194194

195+
## State-key mode
196+
197+
An upgrade never changes how the state partition is unlocked. The node reads
198+
its state-key mode from the partition's LUKS2 header on every boot, so the mode
199+
chosen at install (from `state_key.mode`, or the installing image's `STATEKEY`
200+
default) carries over to any image, whatever `STATEKEY` the new image was built
201+
with. A `nodeid` node upgraded to a `STATEKEY=tpm` image stays `nodeid`, and
202+
the reverse.
203+
195204
## TPM-backed nodes
196205

197-
On a `STATEKEY=tpm` node the state-partition key is sealed to PCR 7 (Secure
206+
On a `tpm`-mode node the state-partition key is sealed to PCR 7 (Secure
198207
Boot policy) and PCR 11. PCR 11 is where systemd-stub measures the UKI, so
199208
every new image changes it, and a key sealed only to the running image would
200209
not unseal after the upgrade.

‎internal/config/config.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,24 @@ type Config struct {
144144
// StateKey selects the protector for the encrypted state-partition key. Mode is
145145
// "" (build-time default) | "nodeid" | "tpm" | "kms". It is carried in the proto
146146
// MachineConfig so the choice survives ApplyConfig and reaches an installed node
147-
// (the maintenance installer reconstructs the staged YAML from the proto).
147+
// (the maintenance installer reconstructs the staged YAML from the proto). The
148+
// mode takes effect when the state volume is first sealed; every later boot
149+
// reads it from the volume.
148150
type StateKey struct {
149151
Mode string `yaml:"mode"`
150152
KMS *KmsStateKey `yaml:"kms"`
151153
}
152154

155+
// CheckSealed refuses a mode other than sealed, the mode the node's state
156+
// volume was sealed with at install. An empty mode asks for nothing and passes.
157+
func (sk StateKey) CheckSealed(sealed string) error {
158+
if sk.Mode == "" || sk.Mode == sealed {
159+
return nil
160+
}
161+
return fmt.Errorf("config: state_key.mode %q: this node's state volume is sealed in %q mode, which is fixed at install; reinstall the node to change it",
162+
sk.Mode, sealed)
163+
}
164+
153165
// KmsStateKey configures the envelope-encryption KMS that seals/unseals the
154166
// state key. Only first-boot provisioning reads this from the machine config;
155167
// later boots recover the endpoint and sealed blob from the LUKS2 header token.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package config
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+
"strings"
23+
"testing"
24+
)
25+
26+
// The state-key mode is fixed when the volume is sealed at install. A config
27+
// may repeat it or leave it empty, but may not name another mode.
28+
func TestStateKeyCheckSealed(t *testing.T) {
29+
cases := []struct {
30+
name, configured, sealed string
31+
wantErr bool
32+
}{
33+
{"empty keeps the sealed mode", "", StateKeyModeTPM, false},
34+
{"same mode", StateKeyModeNodeID, StateKeyModeNodeID, false},
35+
{"tpm on a nodeid volume", StateKeyModeTPM, StateKeyModeNodeID, true},
36+
{"nodeid on a tpm volume", StateKeyModeNodeID, StateKeyModeTPM, true},
37+
{"kms on a tpm volume", StateKeyModeKMS, StateKeyModeTPM, true},
38+
}
39+
for _, tc := range cases {
40+
t.Run(tc.name, func(t *testing.T) {
41+
err := StateKey{Mode: tc.configured}.CheckSealed(tc.sealed)
42+
if (err != nil) != tc.wantErr {
43+
t.Fatalf("CheckSealed = %v, want error %v", err, tc.wantErr)
44+
}
45+
if err != nil && (!strings.Contains(err.Error(), tc.configured) || !strings.Contains(err.Error(), tc.sealed)) {
46+
t.Errorf("error %q should name both modes", err)
47+
}
48+
})
49+
}
50+
}

‎internal/init/configload.go‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,11 @@ func loadOrSeedConfig(store *config.FileStore, stage espStageAccessors) (*config
100100
return nil, fmt.Errorf("%w: no persisted config on an installed node", errEnterMaintenance)
101101
}
102102

103-
// preUnlockStateKey resolves the state-key selection readable before the
104-
// encrypted volume is open: the ESP stage (present on first boot with the
105-
// operator's config). It parses only the state_key section, tolerating an
106-
// absent or unparseable stage by returning the zero StateKey — the caller then
107-
// falls back to the build-time default and the volume opens with the token on a
108-
// later boot. It never fails the boot: pre-unlock config is best-effort.
103+
// preUnlockStateKey reads the first boot's state-key selection from the ESP
104+
// stage, which carries the operator's config before the volume exists. It
105+
// parses only the state_key section, tolerating an absent or unparseable stage
106+
// by returning the zero StateKey, so the caller falls back to the build-time
107+
// default. Later boots never call it: they read the mode from the volume.
109108
func preUnlockStateKey(stage espStageAccessors) config.StateKey {
110109
if stage.stageReader == nil {
111110
return config.StateKey{}

‎internal/init/kms_protector.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"encoding/json"
2525
"errors"
2626
"fmt"
27+
"strconv"
2728

2829
"github.com/CryptOS-PKI/cryptos/internal/config"
2930
"github.com/CryptOS-PKI/cryptos/internal/kms"
@@ -42,13 +43,19 @@ type kmsProtector struct {
4243
newProvider func(endpoint string, trustPEM []byte) (kms.Provider, error)
4344
}
4445

46+
// kmsTokenType is the LUKS2 token type of the KMS protector's token. The type
47+
// is also how a later boot knows the volume was sealed in kms mode.
48+
const kmsTokenType = "cryptos-kms"
49+
4550
// kmsToken is the JSON persisted in the LUKS2 header token. It carries the
4651
// endpoint and trust bundle so later boots can rebuild the Provider without the
47-
// machine config, plus the sealed DEK blob.
52+
// machine config, plus the sealed DEK blob. LUKS2 requires type and keyslots.
4853
type kmsToken struct {
49-
Endpoint string `json:"endpoint"`
50-
TrustPEM string `json:"trust_pem"`
51-
Sealed []byte `json:"sealed"`
54+
Type string `json:"type"`
55+
Keyslots []string `json:"keyslots"`
56+
Endpoint string `json:"endpoint"`
57+
TrustPEM string `json:"trust_pem"`
58+
Sealed []byte `json:"sealed"`
5259
}
5360

5461
// newKMSProtector builds a kmsProtector from the machine-config KMS settings.
@@ -99,6 +106,8 @@ func (p *kmsProtector) ProvisionKey(ctx context.Context) (key, token []byte, err
99106
return nil, nil, fmt.Errorf("kms: seal: %w", err)
100107
}
101108
token, err = json.Marshal(kmsToken{
109+
Type: kmsTokenType,
110+
Keyslots: []string{strconv.Itoa(stateKeyslot)},
102111
Endpoint: p.endpoint,
103112
TrustPEM: string(p.trustPEM),
104113
Sealed: sealed,

‎internal/init/maintenance.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func runMaintenance(ctx context.Context) error {
140140
// The listener setup mirrors runMaintenance: self-signed cert, client auth OFF
141141
// (Talos --insecure), no-op auditor. It parks until a shutdown signal or a
142142
// successful re-provision (which triggers reboot), then returns so PID 1 reboots.
143-
func runReprovisionMaintenance(ctx context.Context, cfgStore *config.FileStore) error {
143+
func runReprovisionMaintenance(ctx context.Context, cfgStore *config.FileStore, sealedMode string) error {
144144
if err := netlink.BringUpLoopback(); err != nil {
145145
return err
146146
}
@@ -154,7 +154,7 @@ func runReprovisionMaintenance(ctx context.Context, cfgStore *config.FileStore)
154154
// client before tearing down the connection, then this function returns and
155155
// PID 1 reboots into the ceremony.
156156
rebootCh := make(chan struct{}, 1)
157-
rp := &reprovisioner{store: cfgStore, rebootCh: rebootCh}
157+
rp := &reprovisioner{store: cfgStore, rebootCh: rebootCh, sealedMode: sealedMode}
158158

159159
srv, err := cgrpc.NewMaintenance(cgrpc.ServerConfig{
160160
TLSConfig: MaintenanceServerTLSConfig(serverCert),

‎internal/init/reprovision.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import (
5050
type reprovisioner struct {
5151
store *config.FileStore
5252
rebootCh chan struct{}
53+
// sealedMode is the state-key mode the kept state volume was sealed with.
54+
sealedMode string
5355
}
5456

5557
// Install validates cfg, persists it to the mounted state store, signals a
@@ -60,6 +62,9 @@ func (r *reprovisioner) Install(_ context.Context, cfg *cryptosv1.MachineConfig)
6062
if err != nil {
6163
return nil, status.Errorf(codes.InvalidArgument, "reprovision: parse: %v", err)
6264
}
65+
if err := parsed.StateKey.CheckSealed(r.sealedMode); err != nil {
66+
return nil, status.Errorf(codes.FailedPrecondition, "reprovision: %v", err)
67+
}
6368
if err := parsed.Validate(); err != nil {
6469
return nil, status.Errorf(codes.InvalidArgument, "reprovision: validate: %v", err)
6570
}

‎internal/init/reprovision_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"google.golang.org/grpc/codes"
2626
"google.golang.org/grpc/status"
2727

28+
cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1"
2829
"github.com/CryptOS-PKI/cryptos/internal/config"
2930
)
3031

@@ -114,3 +115,27 @@ func TestReprovisioner_InvalidConfig(t *testing.T) {
114115
// expected: no reboot signal
115116
}
116117
}
118+
119+
// Re-provisioning keeps the state volume, so it keeps the mode the volume was
120+
// sealed with. A config naming another mode is refused, not persisted, and
121+
// does not reboot.
122+
func TestReprovisioner_RefusesStateKeyModeChange(t *testing.T) {
123+
store := config.NewFileStore(t.TempDir())
124+
rebootCh := make(chan struct{}, 1)
125+
rp := &reprovisioner{store: store, rebootCh: rebootCh, sealedMode: config.StateKeyModeNodeID}
126+
127+
cfg := validMachineConfig()
128+
cfg.StateKey = &cryptosv1.StateKey{Mode: config.StateKeyModeTPM}
129+
_, err := rp.Install(context.Background(), cfg)
130+
if got := status.Code(err); got != codes.FailedPrecondition {
131+
t.Fatalf("Install code = %v, want FailedPrecondition (err: %v)", got, err)
132+
}
133+
if _, _, ok, _ := store.Read(); ok {
134+
t.Error("config persisted after a refused re-provision")
135+
}
136+
select {
137+
case <-rebootCh:
138+
t.Error("reboot signalled after a refused re-provision")
139+
default:
140+
}
141+
}

‎internal/init/run.go‎

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -210,45 +210,30 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) {
210210
}
211211
begin("state volume")
212212

213-
// 3. State-key + Root-key backends (TPM-sealed by default; nodeID/software
214-
// or KMS-wrapped for the TPM-less variants). The state-key selection is
215-
// resolved pre-unlock: on first boot the ESP-staged config (readable before
216-
// the volume opens) supplies the kms endpoint for ProvisionKey; on later
217-
// boots the staged config is gone and the build-time StateKeyMode default
218-
// applies (RecoverKey then reads the endpoint from the LUKS2 token, not
219-
// config). The effective mode is the staged config's when set, else the
220-
// build-time default.
221-
sk := preUnlockStateKey(realESPStageAccessors())
222-
mode := StateKeyMode
223-
if sk.Mode != "" {
224-
mode = sk.Mode
225-
}
226-
protector, rootBackend, closeBackends, tpmState, err := newStateKeyBackends(mode, sk)
227-
if err != nil {
228-
return err
229-
}
230-
defer closeBackends()
231-
log.Printf("state key mode: %s", protector.Name())
232-
233-
// 4. Open (or first-boot-format) the encrypted state volume. Resolve the
234-
// state partition by its GPT name via sysfs (the image has no udev, so the
235-
// by-partlabel symlinks never exist); devtmpfs has created the /dev node.
236-
// First-boot is decided from the partition itself (!IsLUKS), not from config,
237-
// because config does not exist yet.
213+
// 3. Resolve the state partition by its GPT name via sysfs (the image has no
214+
// udev, so the by-partlabel symlinks never exist); devtmpfs has created the
215+
// /dev node. First-boot is decided from the partition itself (!IsLUKS), not
216+
// from config, because config does not exist yet.
238217
stateDevice, err := resolveStateDevice(StateLabel)
239218
if err != nil {
240219
return err
241220
}
242221
paths.Device = stateDevice
243222
dev := &luks.Device{Path: paths.Device, Runner: &luks.ExecRunner{Binary: cryptsetupBinary}}
244-
firstBoot := !dev.IsLUKS(ctx)
245-
vol, err := OpenStateVolume(ctx, StateVolumeConfig{
246-
Protector: protector, Device: dev, MappedName: StateMappedName,
247-
TokenID: StateTokenID, FirstBoot: firstBoot,
248-
})
223+
224+
// 4. State-key + Root-key backends (TPM-sealed by default; nodeID/software
225+
// or KMS-wrapped for the TPM-less variants), then open (or first-boot-format)
226+
// the encrypted state volume with them.
227+
st, err := stateUnlocker{
228+
device: dev, stage: realESPStageAccessors(),
229+
buildDefault: StateKeyMode, newBackends: newStateKeyBackends,
230+
}.unlock(ctx)
249231
if err != nil {
250232
return err
251233
}
234+
defer st.close()
235+
log.Printf("state key mode: %s", st.protector.Name())
236+
mode, rootBackend, tpmState, vol, firstBoot := st.mode, st.root, st.tpmState, st.vol, st.firstBoot
252237
if firstBoot {
253238
if err := mkfsExt4(vol.Path); err != nil {
254239
return err
@@ -292,10 +277,13 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) {
292277
// which persists the applied config to the mounted state and reboots
293278
// into the ceremony, rather than the bare-disk installer.
294279
log.Printf("REPROVISION: %v", err)
295-
return runReprovisionMaintenance(ctx, cfgStore)
280+
return runReprovisionMaintenance(ctx, cfgStore, mode)
296281
}
297282
return err
298283
}
284+
if err := cfg.StateKey.CheckSealed(mode); err != nil {
285+
log.Printf("state key: %v; using the sealed mode", err)
286+
}
299287
done()
300288
begin("network")
301289

@@ -441,7 +429,7 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) {
441429
Identity: node.NewIdentityProvider(store),
442430
Status: statusProv,
443431
Ceremony: eng,
444-
ConfigStore: node.NewConfigStore(cfgStore).WithIssuer(issuerFunc),
432+
ConfigStore: node.NewConfigStore(cfgStore).WithIssuer(issuerFunc).WithSealedStateKeyMode(mode),
445433
}
446434
}
447435

0 commit comments

Comments
 (0)