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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal/
init/ # supervisor + boot bring-up
netlink/ # NIC bring-up via rtnetlink
mounts/ # early mount sequence
timesync/ # client-only SNTPv4: boot step, periodic slew, clock floor
tpm/ # go-tpm wrapper, SRK provisioning, crypto.Signer impl
ca/ # RFC 5280 cert template builder
ceremony/ # first-boot ceremony state machine
Expand Down Expand Up @@ -119,6 +120,8 @@ Every issued certificate comes from a named profile in the machine config. [`doc

A node keeps every certificate it issues. `cryptosctl ca list-issued` lists them, `ca get-issued --serial <hex>` prints one as PEM with its chain up to the root and its status (`valid`, `revoked` or `expired`), and `ca revoke` revokes one. [`docs/issued-certificates.md`](docs/issued-certificates.md) covers all three.

A node keeps its clock in sync over SNTP with the servers in `network.ntp_servers` (or its DHCP lease), and refuses to sign certificates until the first sync when a time source is configured. CRL and OCSP are never held up. [`docs/time-sync.md`](docs/time-sync.md) covers the servers, the signing gate and its `pki.allow_unsynced_clock` override, and the `Clock:` status line.

Issuing LDAPS and KDC certificates to Active Directory domain controllers, including `certreq` on Server Core, is covered in [`docs/active-directory.md`](docs/active-directory.md).

### Rebooting or powering off a node
Expand Down
46 changes: 46 additions & 0 deletions cmd/cryptosctl/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"testing"
"time"

"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"

cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1"
Expand Down Expand Up @@ -292,3 +293,48 @@ func selfSignedCA(t *testing.T) []byte {
}
return der
}

func TestHumanStatusShowsClock(t *testing.T) {
synced := humanStatus(&cryptosv1.NodeStatus{TimeSync: &cryptosv1.TimeSyncStatus{
State: cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED,
Source: cryptosv1.TimeSource_TIME_SOURCE_MACHINE_CONFIG,
Servers: []string{"time.example.org", "192.0.2.123"},
LastServer: "time.example.org",
LastOffset: durationpb.New(-12500 * time.Microsecond),
Stratum: 2,
LastSync: timestamppb.New(time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)),
SteppedAtBoot: true,
}})
for _, want := range []string{
"Clock:", "SYNCED", "MACHINE_CONFIG", "time.example.org", "offset -12.5ms", "stratum 2",
"synced 2026-09-29T12:00:00Z", "stepped at boot",
} {
if !strings.Contains(synced, want) {
t.Errorf("humanStatus missing %q in:\n%s", want, synced)
}
}

unsynced := humanStatus(&cryptosv1.NodeStatus{TimeSync: &cryptosv1.TimeSyncStatus{
State: cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED,
Source: cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE,
Servers: []string{"192.0.2.123"},
LastError: "timesync: sources disagree",
}})
for _, want := range []string{"UNSYNCED", "DHCP_LEASE", "192.0.2.123", "sources disagree"} {
if !strings.Contains(unsynced, want) {
t.Errorf("humanStatus missing %q in:\n%s", want, unsynced)
}
}

none := humanStatus(&cryptosv1.NodeStatus{TimeSync: &cryptosv1.TimeSyncStatus{
State: cryptosv1.TimeSyncState_TIME_SYNC_STATE_NOT_CONFIGURED,
Source: cryptosv1.TimeSource_TIME_SOURCE_NONE,
}})
if !strings.Contains(none, "NOT_CONFIGURED") || !strings.Contains(none, "hardware clock") {
t.Errorf("humanStatus for no time source:\n%s", none)
}

if bare := humanStatus(&cryptosv1.NodeStatus{}); strings.Contains(bare, "Clock:") {
t.Errorf("humanStatus printed an unreported clock line:\n%s", bare)
}
}
5 changes: 4 additions & 1 deletion cmd/cryptosctl/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ func newConfigApplyCmd(opts *globalOpts) *cobra.Command {
return err
}
}
if w := cfg.RevocationResolverWarning(); w != "" {
for _, w := range []string{cfg.RevocationResolverWarning(), cfg.NTPResolverWarning(), cfg.TimeSourceWarning()} {
if w == "" {
continue
}
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: %s\n", w); err != nil {
return err
}
Expand Down
44 changes: 44 additions & 0 deletions cmd/cryptosctl/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,47 @@ func TestConfigApply_WarnsWhenRevocationHostHasNoResolver(t *testing.T) {
})
}
}

// apply warns when the node has no declared time source, and when a hostname
// time server has no static resolver to find it.
func TestConfigApply_WarnsAboutTimeSource(t *testing.T) {
for _, tc := range []struct {
name string
extra string
wantWarns []string
noWarns []string
}{
{"no ntp servers", "", []string{"network.ntp_servers is empty"}, nil},
{"literal ntp server", " ntp_servers: [10.0.0.123]\n", nil, []string{"ntp_servers"}},
{"hostname ntp server without nameservers", " ntp_servers: [time.example.org]\n", []string{"time.example.org", "network.nameservers"}, []string{"is empty. The node syncs"}},
{"hostname ntp server with nameservers", " ntp_servers: [time.example.org]\n nameservers: [10.0.0.53]\n", nil, []string{"ntp_servers"}},
} {
t.Run(tc.name, func(t *testing.T) {
raw := strings.Replace(string(buildMachineYAMLWithInstall(t, "/dev/vdb")),
" gateway: 10.0.0.1\n", " gateway: 10.0.0.1\n"+tc.extra, 1)
file := filepath.Join(t.TempDir(), "machine.yaml")
if err := os.WriteFile(file, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
missing := filepath.Join(t.TempDir(), "missing.pem")
cmd := newConfigApplyCmd(&globalOpts{endpoint: "127.0.0.1:1", identityCert: missing, identityKey: missing, trustCert: missing})
stderr := &bytes.Buffer{}
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(stderr)
cmd.SetArgs([]string{"-f", file})
if err := cmd.Execute(); err == nil {
t.Fatal("expected the dial to fail")
}
for _, w := range tc.wantWarns {
if !strings.Contains(stderr.String(), w) {
t.Errorf("stderr missing %q:\n%s", w, stderr.String())
}
}
for _, w := range tc.noWarns {
if strings.Contains(stderr.String(), w) {
t.Errorf("stderr unexpectedly contains %q:\n%s", w, stderr.String())
}
}
})
}
}
30 changes: 30 additions & 0 deletions cmd/cryptosctl/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ func humanStatus(s *cryptosv1.NodeStatus) string {
if s.GetConfigRebootPending() {
fmt.Fprintf(&b, "Reboot: pending (the stored config changes take effect at the next boot)\n")
}
if ts := s.GetTimeSync(); ts != nil {
fmt.Fprintf(&b, "Clock: %s\n", humanTimeSync(ts))
}
return b.String()
}

Expand Down Expand Up @@ -139,6 +142,33 @@ func humanResolver(r *cryptosv1.ResolverStatus) string {
return line
}

// humanTimeSync renders the time-sync state as one line: the state, the
// source and servers, then either the latest good sync or why the latest
// attempt did not adjust the clock.
func humanTimeSync(ts *cryptosv1.TimeSyncStatus) string {
line := trimEnum(ts.GetState().String(), "TIME_SYNC_STATE_")
if ts.GetSource() == cryptosv1.TimeSource_TIME_SOURCE_NONE {
return line + " (no time source; running on the hardware clock)"
}
line += " " + trimEnum(ts.GetSource().String(), "TIME_SOURCE_")
if sv := ts.GetServers(); len(sv) > 0 {
line += " " + strings.Join(sv, ", ")
}
if ts.GetLastSync() != nil {
line += fmt.Sprintf(" (via %s, offset %v, stratum %d, synced %s",
ts.GetLastServer(), ts.GetLastOffset().AsDuration(), ts.GetStratum(),
ts.GetLastSync().AsTime().UTC().Format(time.RFC3339))
if ts.GetSteppedAtBoot() {
line += ", stepped at boot"
}
line += ")"
}
if e := ts.GetLastError(); e != "" {
line += ": " + e
}
return line
}

// trimEnum strips a proto enum prefix for human display.
func trimEnum(s, prefix string) string {
return strings.TrimPrefix(s, prefix)
Expand Down
88 changes: 88 additions & 0 deletions docs/time-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Time sync

A CA's clock sets the notBefore and notAfter of every certificate, the CRL's
thisUpdate and nextUpdate, OCSP response times and audit times. A node keeps its
clock in sync with a small client-only SNTPv4 (RFC 4330, and RFC 5905 section
14) built into init. It never serves time.

## Configure the servers

```yaml
network:
interface: eth0
address: 10.0.0.10/24
gateway: 10.0.0.1
nameservers: [10.0.0.53]
ntp_servers: [10.0.0.123, time.example.org] # at most 3, IPv4 literals or hostnames
```

A hostname is resolved through the node's resolver at every poll, so a DNS change
takes effect without a reboot. `cryptosctl config apply` warns when a hostname
is listed and `nameservers` is empty: the node then resolves it only if its DHCP
lease supplies DNS servers.

With `ntp_servers` empty the node uses the NTP servers from its kernel DHCP
lease (option 42), if it got any. With neither, the node runs on its hardware
clock, and `config apply` warns about it. That is the expected setup for an
offline Root.

## What the node does

- **At boot**, after the network and resolver are up and before etcd, the
listeners and signing start, the node queries every server, retrying an
unanswered one up to three times 2 seconds apart. It gives up after about
10 seconds and boots anyway. An offset above 128 ms is stepped; a smaller one
is slewed.
- **After boot** it polls each server every 64 seconds, backing off to 1024
seconds while a server does not answer. Small offsets are slewed. A forward
offset above 128 ms is stepped. A backward one is refused and logged as an
error, because it would reorder audit and issuance times on a running CA.
- **Several servers:** the node takes the median offset. If two or more servers
answered and their offsets are more than 1 second apart, it adjusts nothing
and reports `sources disagree`. The 3-server limit and the 1-second window are
fixed.
- **Replies** are checked as RFC 4330 and RFC 5905 require. The request carries a
random transmit timestamp that the reply must echo, and the socket only takes
replies from the server it asked. A server that sends a kiss-o'-death `DENY` or
`RSTR` is not queried again until reboot; `RATE` halves how often it is polled.
- **The clock floor:** the clock is never stepped behind the latest of the image
build time, the last good sync, and the latest notBefore the node has issued.
The floor is kept on the encrypted state volume. A server whose time is behind
it is refused and logged as an error.

The time is not authenticated (no NTS). The random nonce, the median and
agreement rule, the floor, and the forward-only rule after boot limit what one
bad or spoofed server can do.

## The signing gate

While a time source is configured or leased but the clock has not synced yet
this boot, the node refuses to sign certificates: subordinate CA signing and
every leaf path, including ACME and EST. The call fails with
`FailedPrecondition` before the CA key is loaded. The gate opens at the first
good sync and stays open for the rest of the boot, even if a later poll fails.

- A node with **no time source** is never gated.
- **CRL and OCSP** are never gated. A stale-looking CRL is better than none.
- `pki.allow_unsynced_clock: true` lifts the gate. Every certificate signed that
way is logged as a warning. Leave it off in production.

```yaml
pki:
allow_unsynced_clock: true # sign on an unsynced clock (lab only)
```

## Status

`cryptosctl status` prints a `Clock:` line: the state, the source and servers,
then the server, offset, stratum and time of the latest good sync, or why the
latest attempt did not adjust the clock.

```text
Clock: SYNCED MACHINE_CONFIG 10.0.0.123, time.example.org (via time.example.org, offset -12.5ms, stratum 2, synced 2026-09-29T12:00:00Z, stepped at boot)
Clock: UNSYNCED DHCP_LEASE 192.0.2.123: timesync: sources disagree: offsets spread 4.9s across 2 servers (more than 1s)
Clock: NOT_CONFIGURED (no time source; running on the hardware clock)
```

The states are `NOT_CONFIGURED`, `PENDING` (configured, no sync finished yet),
`SYNCED` and `UNSYNCED`. The same fields are in `GetStatus` as `time_sync`.
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ type Network struct {
// Search is the ordered DNS search-domain list. When empty the domain
// from the DHCP lease, if any, is used.
Search []string `yaml:"search"`
// NTPServers are the SNTP servers the node keeps its clock in sync with,
// at most MaxNTPServers, each an IPv4 literal or a hostname. A hostname is
// resolved through the node resolver at each sync, so a DNS change is
// picked up without a reboot. When empty the node uses the NTP servers
// from its kernel DHCP lease (option 42), if any; with neither it runs on
// its hardware clock.
NTPServers []string `yaml:"ntp_servers"`
}

// Bootstrap carries the administrator credential trusted on first boot.
Expand Down Expand Up @@ -251,6 +258,13 @@ type PKI struct {
// isolated lab where DNS is not yet wired; production leaves it false so a
// misconfigured URL blocks issuance rather than stamping a dead pointer.
AllowUnverifiedRevocationURL bool `yaml:"allow_unverified_revocation_url"`
// AllowUnsyncedClock overrides the fail-closed clock gate: while a time
// source is configured or leased but the node has not synced its clock
// this boot, certificate signing is refused unless this is true. The dates
// a CA stamps are only as good as its clock, so production leaves it
// false. A node with no time source is never gated, and CRL and OCSP
// generation are never gated.
AllowUnsyncedClock bool `yaml:"allow_unsynced_clock"`
// CRLNextUpdateHours is the CRL validity window: nextUpdate is thisUpdate
// plus this many hours. Zero means the caller's default (168h / one week).
CRLNextUpdateHours uint32 `yaml:"crl_next_update_hours"`
Expand Down Expand Up @@ -543,6 +557,9 @@ func (c *Config) validate(keptSecrets bool) error {
if err := validateSearch(c.Network.Search); err != nil {
return err
}
if err := validateNTPServers(c.Network.NTPServers); err != nil {
return err
}
if err := validateBootstrap(c.Bootstrap); err != nil {
return err
}
Expand Down Expand Up @@ -1083,6 +1100,7 @@ func FromProto(pb *cryptosv1.MachineConfig) (*Config, error) {
c.Network.Gateway = pb.Network.Gateway
c.Network.Nameservers = pb.Network.Nameservers
c.Network.Search = pb.Network.Search
c.Network.NTPServers = pb.Network.NtpServers
}
if pb.Bootstrap != nil {
c.Bootstrap.AdminCertPEM = pb.Bootstrap.AdminCertPem
Expand All @@ -1102,6 +1120,7 @@ func FromProto(pb *cryptosv1.MachineConfig) (*Config, error) {
c.PKI.Profiles = profilesFromProto(pb.Pki.Profiles)
c.PKI.RevocationBaseURL = pb.Pki.RevocationBaseUrl
c.PKI.AllowUnverifiedRevocationURL = pb.Pki.AllowUnverifiedRevocationUrl
c.PKI.AllowUnsyncedClock = pb.Pki.AllowUnsyncedClock
c.PKI.CRLNextUpdateHours = pb.Pki.CrlNextUpdateHours
c.PKI.RevocationHTTPPort = pb.Pki.RevocationHttpPort
c.PKI.RootLeafIssuance = pb.Pki.RootLeafIssuance
Expand Down Expand Up @@ -1159,6 +1178,7 @@ func (c *Config) ToProto() *cryptosv1.MachineConfig {
RootLeafIssuance: c.PKI.RootLeafIssuance,
Acme: acmeToProto(c.PKI.ACME),
Est: estToProto(c.PKI.EST),
AllowUnsyncedClock: c.PKI.AllowUnsyncedClock,
}
if c.PKI.Parent != nil {
pki.Parent = &cryptosv1.Parent{
Expand Down Expand Up @@ -1196,6 +1216,7 @@ func (c *Config) ToProto() *cryptosv1.MachineConfig {
Gateway: c.Network.Gateway,
Nameservers: c.Network.Nameservers,
Search: c.Network.Search,
NtpServers: c.Network.NTPServers,
},
Bootstrap: &cryptosv1.Bootstrap{
AdminCertPem: c.Bootstrap.AdminCertPEM,
Expand Down
Loading