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
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
205dd6475254419977b831d4b9feb35bc2b37caa:internal/config/protocols_test.go:generic-api-key:30
14 changes: 9 additions & 5 deletions cmd/cryptosctl/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ func newConfigGetCmd(opts *globalOpts) *cobra.Command {
Short: "Print the node's current machine configuration",
Long: "Print the node's current machine configuration as YAML, ready to edit and " +
"feed back to `config apply -f`.\n\n" +
"The acme and est blocks are not returned: they carry secrets and are " +
"deliberately absent from the wire format. That does not break a " +
"read-edit-apply cycle, because the node carries those blocks forward from " +
"its existing config when a new one is applied.",
"The acme and est blocks are returned with their secrets blank: " +
"hmac_key_base64 and password_sha256 are write-only. Leave a blank secret " +
"as it is and `config apply` keeps the one the node stores for that key_id " +
"or username; set it to replace it. A new key_id or username needs its " +
"secret. Switching a protocol on or off, or changing its settings, takes " +
"effect at the next reboot.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
client, closeConn, err := dial(opts)
Expand Down Expand Up @@ -117,7 +119,9 @@ func newConfigApplyCmd(opts *globalOpts) *cobra.Command {
}
// Validate client-side so the operator gets a clear error
// before the round-trip; the node re-validates authoritatively.
cfg, err := config.Parse(raw)
// A blank protocol secret passes here because the node fills it
// from the one it stores.
cfg, err := config.ParseForApply(raw)
if err != nil {
return err
}
Expand Down
144 changes: 144 additions & 0 deletions cmd/cryptosctl/protocols_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

/*
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 (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"

cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1"
)

func TestHumanStatusShowsProtocols(t *testing.T) {
s := &cryptosv1.NodeStatus{
Role: cryptosv1.NodeRole_NODE_ROLE_ISSUING,
Protocols: []*cryptosv1.ProtocolStatus{
{Protocol: cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_ACME, Configured: true, Running: true},
{Protocol: cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_EST, Configured: true, Running: false, RebootPending: true},
},
ConfigRebootPending: true,
}
out := humanStatus(s)
for _, want := range []string{
"Protocols:", "ACME on", "EST on (not running, reboot pending)",
"Reboot:", "pending",
} {
if !strings.Contains(out, want) {
t.Errorf("humanStatus missing %q in:\n%s", want, out)
}
}

off := humanStatus(&cryptosv1.NodeStatus{
Protocols: []*cryptosv1.ProtocolStatus{
{Protocol: cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_ACME},
{Protocol: cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_EST, Running: true, RebootPending: true},
},
})
for _, want := range []string{"ACME off", "EST off (still running, reboot pending)"} {
if !strings.Contains(off, want) {
t.Errorf("humanStatus missing %q in:\n%s", want, off)
}
}
if strings.Contains(off, "Reboot:") {
t.Errorf("humanStatus flagged a reboot the node did not report:\n%s", off)
}

// A node that does not report protocols (maintenance mode, an older node)
// prints no Protocols line rather than an empty one.
if bare := humanStatus(&cryptosv1.NodeStatus{}); strings.Contains(bare, "Protocols:") {
t.Errorf("humanStatus printed unreported protocols:\n%s", bare)
}
}

const protocolsConfig = `apiVersion: cryptos.dev/v1alpha1
kind: MachineConfig
metadata:
name: pki-issuing-test
role:
kind: issuing
network:
interface: eth0
address: 192.0.2.30/24
gateway: 192.0.2.1
pki:
root_key_alg: ECDSA-P384
root_subject:
common_name: Protocols Issuing CA
parent:
ca_cert_sha256: abababababababababababababababababababababababababababababababab
profiles:
- name: leaf-server
key_alg: ECDSA-P384
validity_days: 90
key_usage: [digital_signature]
ext_key_usage: [server_auth]
acme:
base_url: https://ca.example.org/acme
profile: leaf-server
external_account_keys:
- key_id: ops
hmac_key_base64: EAB_KEY
bootstrap:
admin_cert_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
`

// config get prints the EAB secret blank, and feeding that output back to
// config apply keeps the secret the node holds.
func TestConfigGetApply_KeepsWriteOnlySecret(t *testing.T) {
ts := startTestServer(t)
dir := t.TempDir()
secret := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
path := filepath.Join(dir, "machine.yaml")
if err := os.WriteFile(path, []byte(strings.Replace(protocolsConfig, "EAB_KEY", secret, 1)), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
out, err := ts.run(t, "config", "apply", "-f", path)
if err != nil {
t.Fatalf("config apply: %v (out=%s)", err, out)
}
if !strings.Contains(out, "requires_reboot=true") {
t.Errorf("switching ACME on did not report requires_reboot:\n%s", out)
}

got, err := ts.run(t, "config", "get")
if err != nil {
t.Fatalf("config get: %v (out=%s)", err, got)
}
if strings.Contains(got, secret) {
t.Fatalf("config get printed the EAB secret:\n%s", got)
}
if !strings.Contains(got, "key_id: ops") {
t.Errorf("config get dropped the key_id:\n%s", got)
}

back := filepath.Join(dir, "back.yaml")
if err := os.WriteFile(back, []byte(got), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
out, err = ts.run(t, "config", "apply", "-f", back)
if err != nil {
t.Fatalf("config apply of the config get output: %v (out=%s)", err, out)
}
if !strings.Contains(out, "requires_reboot=false") {
t.Errorf("an unchanged re-apply reported a reboot:\n%s", out)
}
}
35 changes: 35 additions & 0 deletions cmd/cryptosctl/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,44 @@ func humanStatus(s *cryptosv1.NodeStatus) string {
if r := s.GetResolver(); r != nil {
fmt.Fprintf(&b, "DNS: %s\n", humanResolver(r))
}
if ps := s.GetProtocols(); len(ps) > 0 {
fmt.Fprintf(&b, "Protocols: %s\n", humanProtocols(ps))
}
if s.GetConfigRebootPending() {
fmt.Fprintf(&b, "Reboot: pending (the stored config changes take effect at the next boot)\n")
}
return b.String()
}

// humanProtocols renders the enrolment protocols as one line: each one on or
// off as stored, with a note when this boot does not match it yet.
func humanProtocols(ps []*cryptosv1.ProtocolStatus) string {
parts := make([]string, 0, len(ps))
for _, p := range ps {
part := trimEnum(p.GetProtocol().String(), "SERVICE_PROTOCOL_")
if p.GetConfigured() {
part += " on"
} else {
part += " off"
}
var notes []string
switch {
case p.GetConfigured() && !p.GetRunning():
notes = append(notes, "not running")
case !p.GetConfigured() && p.GetRunning():
notes = append(notes, "still running")
}
if p.GetRebootPending() {
notes = append(notes, "reboot pending")
}
if len(notes) > 0 {
part += " (" + strings.Join(notes, ", ") + ")"
}
parts = append(parts, part)
}
return strings.Join(parts, ", ")
}

// humanPreflight renders the revocation preflight as one line: the state, the
// base URL checked, when, and the last error while failing.
func humanPreflight(p *cryptosv1.RevocationPreflight) string {
Expand Down
38 changes: 36 additions & 2 deletions docs/acme.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,42 @@ Accepted reason codes are `unspecified` (0), `keyCompromise` (1),
`privilegeWithdrawn` (9). `certificateHold` is refused: this CA has no hold
workflow, and accepting the request would imply one.

## Switching it on, changing it and switching it off

ACME runs on an intermediate or issuing node only. A Root serves no enrolment
protocol, so a config that sets `pki.acme` on a Root is refused by
`config apply`, by the maintenance install and by `ceremony start --config`.

> [!CAUTION]
> Don't add `pki.acme` to a Root's config. It is refused, and nothing is
> stored. Serve ACME from an issuing node under that Root instead.

To switch ACME on, add the `pki.acme` block and run `config apply`. To
switch it off, remove the block and apply again. Over the API the block is
`Pki.acme` with an explicit `enabled` flag: `enabled: false` switches it
off, and leaving the block out of an `ApplyConfig` keeps what the node has.

> [!WARNING]
> Every ACME change, switching it on or off included, takes effect at the
> next reboot, not when you apply it. `config apply` stores the change and
> prints `requires_reboot=true`; the listener starts, stops or picks up the new
> settings only when the node boots again. Plan the reboot for a maintenance
> window, because the node stops issuing while it restarts, then use
> `cryptosctl reboot`.

`cryptosctl status` shows what is stored against what is running until then:

```text
Protocols: ACME on (not running, reboot pending), EST off
Reboot: pending (the stored config changes take effect at the next boot)
```

`hmac_key_base64` is write-only. `cryptosctl config get` prints it blank, with its
`key_id`. Leave a blank value as it is and `config apply` keeps the one the
node stores for that `key_id`; set a value to replace it. A new `key_id`
needs its value, and an apply that leaves it blank is refused. Removing an
entry revokes it.

## Limits worth knowing before you deploy

- **Key change is not implemented.** RFC 8555 section 7.3.5 is absent from the
Expand All @@ -148,5 +184,3 @@ workflow, and accepting the request would imply one.
order that justified it.
- **Validation runs inline on the challenge POST**, bounded by a ten-second
timeout. The client's first poll already carries the answer.
- **The `pki.acme` block is not yet carried in the proto machine config**, so
it survives a staged YAML boot but not an `ApplyConfig` from a manager.
38 changes: 36 additions & 2 deletions docs/est.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,42 @@ operation. Set `label: issuing` and every path moves under
`/.well-known/est/issuing/`. The unlabelled paths then return 404, so a label
is routing rather than decoration.

## Switching it on, changing it and switching it off

EST runs on an intermediate or issuing node only. A Root serves no enrolment
protocol, so a config that sets `pki.est` on a Root is refused by
`config apply`, by the maintenance install and by `ceremony start --config`.

> [!CAUTION]
> Don't add `pki.est` to a Root's config. It is refused, and nothing is
> stored. Serve EST from an issuing node under that Root instead.

To switch EST on, add the `pki.est` block and run `config apply`. To
switch it off, remove the block and apply again. Over the API the block is
`Pki.est` with an explicit `enabled` flag: `enabled: false` switches it
off, and leaving the block out of an `ApplyConfig` keeps what the node has.

> [!WARNING]
> Every EST change, switching it on or off included, takes effect at the
> next reboot, not when you apply it. `config apply` stores the change and
> prints `requires_reboot=true`; the listener starts, stops or picks up the new
> settings only when the node boots again. Plan the reboot for a maintenance
> window, because the node stops issuing while it restarts, then use
> `cryptosctl reboot`.

`cryptosctl status` shows what is stored against what is running until then:

```text
Protocols: ACME off, EST on (not running, reboot pending)
Reboot: pending (the stored config changes take effect at the next boot)
```

`password_sha256` is write-only. `cryptosctl config get` prints it blank, with its
`username`. Leave a blank value as it is and `config apply` keeps the one the
node stores for that `username`; set a value to replace it. A new `username`
needs its value, and an apply that leaves it blank is refused. Removing an
entry revokes it.

## Limits worth knowing before you deploy

- **Wildcards are refused.** Nothing in EST establishes control of a label
Expand All @@ -211,5 +247,3 @@ is routing rather than decoration.
say answers 204.
- **Issuance is synchronous.** There is no 202 Retry-After manual-approval
flow; a request either yields a certificate or fails.
- **The `pki.est` block is not yet carried in the proto machine config**, so
it survives a staged YAML boot but not an `ApplyConfig` from a manager.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/CryptOS-PKI/cryptos
go 1.25.0

require (
github.com/CryptOS-PKI/api v0.0.0-20260929181434-442c13e13c15
github.com/CryptOS-PKI/api v0.0.0-20260930142054-81494db99991
github.com/google/go-tpm v0.9.8
github.com/google/go-tpm-tools v0.4.8
github.com/spf13/cobra v1.10.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/CryptOS-PKI/api v0.0.0-20260929181434-442c13e13c15 h1:ZSw1gdY7yy2872QDzCnNfchRPA3+d5Bgm4bYVvNZ55A=
github.com/CryptOS-PKI/api v0.0.0-20260929181434-442c13e13c15/go.mod h1:tmeaYhNoUblqPYYNtTbGBGIUsZ7xLATgLdKq4S33rmE=
github.com/CryptOS-PKI/api v0.0.0-20260930142054-81494db99991 h1:gRd3iY+smHPGl98k/T9Ezc9wG9/8hh+kJ8eZoGOOkW8=
github.com/CryptOS-PKI/api v0.0.0-20260930142054-81494db99991/go.mod h1:tmeaYhNoUblqPYYNtTbGBGIUsZ7xLATgLdKq4S33rmE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
Expand Down
5 changes: 4 additions & 1 deletion internal/ceremony/ceremony.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@ func (e *Engine) Start(ctx context.Context, req *cryptosv1.StartCeremonyRequest,
return status.Error(codes.FailedPrecondition, "IDENTITY_EXISTS")
}

// Parse + validate the operator's machine config from the request.
// Parse + validate the operator's machine config from the request. The
// raw YAML is stored below and the Root boots from it, so this is the
// check that keeps an enrolment protocol off a Root: config.Parse refuses
// a Root with pki.acme or pki.est set.
if len(req.MachineConfigYaml) == 0 {
return status.Error(codes.InvalidArgument, "ceremony: machine_config_yaml is required")
}
Expand Down
Loading