From f4e6ad6d462ed4f291b6063020fc1eefe585cd2a Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:15:17 -0400 Subject: [PATCH 1/5] feat(config): add network.ntp_servers and pki.allow_unsynced_clock Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- internal/config/config.go | 21 ++++ internal/config/network_ntp.go | 111 +++++++++++++++++++ internal/config/network_ntp_test.go | 164 ++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 internal/config/network_ntp.go create mode 100644 internal/config/network_ntp_test.go diff --git a/internal/config/config.go b/internal/config/config.go index dd07e76..e89ed46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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"` @@ -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 } @@ -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 @@ -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 @@ -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{ @@ -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, diff --git a/internal/config/network_ntp.go b/internal/config/network_ntp.go new file mode 100644 index 0000000..d8622f9 --- /dev/null +++ b/internal/config/network_ntp.go @@ -0,0 +1,111 @@ +package config + +/* +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 ( + "fmt" + "net/netip" + "strings" +) + +// MaxNTPServers caps network.ntp_servers. It matches the kernel's limit on +// DHCP option 42 servers and the nameserver limit, so a leased list and a +// declared one hold the same number of servers. +const MaxNTPServers = 3 + +// validateNTPServers enforces that each time server is a unicast IPv4 literal +// or a syntactically valid hostname, and that none is listed twice. A literal +// is checked like a nameserver; anything that does not parse as an address is +// checked as a DNS name, which also keeps ports and whitespace out. +func validateNTPServers(servers []string) error { + if len(servers) > MaxNTPServers { + return fmt.Errorf("config: network.ntp_servers: at most %d entries, got %d", MaxNTPServers, len(servers)) + } + seen := make(map[string]bool, len(servers)) + for i, s := range servers { + key, err := ntpServerKey(s) + if err != nil { + return fmt.Errorf("config: network.ntp_servers[%d]: %w", i, err) + } + if seen[key] { + return fmt.Errorf("config: network.ntp_servers[%d]: %s is listed twice", i, s) + } + seen[key] = true + } + return nil +} + +// ntpServerKey validates s and returns the form used to spot duplicates: the +// address for a literal, the lower-cased name without a trailing dot for a +// hostname. +func ntpServerKey(s string) (string, error) { + if _, err := netip.ParseAddr(s); err == nil { + a, err := ParseNameserver(s) + if err != nil { + return "", err + } + return a.String(), nil + } + if err := ValidateSearchDomain(s); err != nil { + return "", fmt.Errorf("must be an IPv4 address or a hostname: %w", err) + } + return strings.ToLower(strings.TrimSuffix(s, ".")), nil +} + +// IsNTPHostname reports whether a validated network.ntp_servers entry is a +// hostname rather than an IPv4 literal. +func IsNTPHostname(s string) bool { + _, err := netip.ParseAddr(s) + return err != nil +} + +// NTPResolverWarning returns a warning when network.ntp_servers names a host +// but network.nameservers is empty, and "" otherwise. Such a node reaches that +// server only if its DHCP lease supplies DNS servers. It is a warning rather +// than a validation error because a lease that carries DNS servers is a valid +// deployment, the same call RevocationResolverWarning makes. +func (c *Config) NTPResolverWarning() string { + if len(c.Network.Nameservers) > 0 { + return "" + } + for _, s := range c.Network.NTPServers { + if IsNTPHostname(s) { + return fmt.Sprintf("network.ntp_servers names the host %q but network.nameservers is empty. "+ + "The node can resolve it only if its DHCP lease supplies DNS servers; if it cannot, "+ + "that server is never queried and, with no other server answering, the node refuses "+ + "to sign until its clock syncs (unless pki.allow_unsynced_clock is set). "+ + "Set network.nameservers, or name the server by IPv4 address.", s) + } + } + return "" +} + +// TimeSourceWarning returns a warning when network.ntp_servers is empty, and +// "" otherwise. The node then keeps its clock in sync only if its DHCP lease +// supplies NTP servers; with none it runs on its hardware clock, which drifts, +// and the dates it stamps drift with it. An offline Root is expected to run +// this way, so it is a warning, not an error. +func (c *Config) TimeSourceWarning() string { + if len(c.Network.NTPServers) > 0 { + return "" + } + return "network.ntp_servers is empty. The node syncs its clock only if its DHCP lease supplies " + + "NTP servers (option 42); with none it runs on its hardware clock, and certificate and CRL " + + "dates follow that clock's drift. Set network.ntp_servers unless the node is meant to run offline." +} diff --git a/internal/config/network_ntp_test.go b/internal/config/network_ntp_test.go new file mode 100644 index 0000000..d520c57 --- /dev/null +++ b/internal/config/network_ntp_test.go @@ -0,0 +1,164 @@ +package config + +/* +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 ( + "strings" + "testing" +) + +func TestParseNetworkNTPServersAndClockOverride(t *testing.T) { + raw := strings.Replace(string(validYAML(t)), " gateway: 10.0.0.1\n", + " gateway: 10.0.0.1\n ntp_servers: [10.0.0.123, time.example.org]\n", 1) + raw = strings.Replace(raw, "pki:\n", "pki:\n allow_unsynced_clock: true\n", 1) + cfg, err := Parse([]byte(raw)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := strings.Join(cfg.Network.NTPServers, ","); got != "10.0.0.123,time.example.org" { + t.Errorf("NTPServers = %q", got) + } + if !cfg.PKI.AllowUnsyncedClock { + t.Error("AllowUnsyncedClock = false, want true") + } +} + +func TestValidateNetworkNTPServers(t *testing.T) { + cases := []struct { + name string + servers []string + ok bool + }{ + {"none", nil, true}, + {"ipv4 literal", []string{"10.0.0.123"}, true}, + {"hostname", []string{"time.example.org"}, true}, + {"single label hostname", []string{"ntp"}, true}, + {"three mixed", []string{"10.0.0.123", "time.example.org", "10.0.1.123"}, true}, + {"four exceeds the cap", []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4"}, false}, + {"ipv6 is refused on an IPv4-only node", []string{"2001:db8::123"}, false}, + {"ipv4-mapped ipv6", []string{"::ffff:10.0.0.123"}, false}, + {"unspecified", []string{"0.0.0.0"}, false}, + {"broadcast", []string{"255.255.255.255"}, false}, + {"multicast", []string{"224.0.1.1"}, false}, + {"empty entry", []string{""}, false}, + {"with a port", []string{"10.0.0.123:123"}, false}, + {"hostname with a port", []string{"time.example.org:123"}, false}, + {"whitespace", []string{"time example.org"}, false}, + {"duplicate literal", []string{"10.0.0.123", "10.0.0.123"}, false}, + {"duplicate hostname ignoring case and trailing dot", []string{"Time.Example.org", "time.example.org."}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cfg.Network.NTPServers = tc.servers + err = cfg.Validate() + if tc.ok && err != nil { + t.Fatalf("Validate: %v", err) + } + if !tc.ok { + if err == nil { + t.Fatal("Validate accepted an invalid ntp_servers list") + } + if !strings.Contains(err.Error(), "network.ntp_servers") { + t.Fatalf("error %q does not name network.ntp_servers", err) + } + } + }) + } +} + +// The time settings must survive ToProto -> FromProto, for the same reason the +// resolver settings must: config reaches an installed node through the proto. +func TestTimeSyncConfigSurvivesProtoRoundTrip(t *testing.T) { + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cfg.Network.NTPServers = []string{"10.0.0.123", "time.example.org"} + cfg.PKI.AllowUnsyncedClock = true + + pb := cfg.ToProto() + if got := strings.Join(pb.Network.NtpServers, ","); got != "10.0.0.123,time.example.org" { + t.Errorf("proto NtpServers = %q", got) + } + if !pb.Pki.AllowUnsyncedClock { + t.Error("proto AllowUnsyncedClock = false") + } + got, err := FromProto(pb) + if err != nil { + t.Fatalf("FromProto: %v", err) + } + if strings.Join(got.Network.NTPServers, ",") != "10.0.0.123,time.example.org" { + t.Errorf("round-tripped NTPServers = %v", got.Network.NTPServers) + } + if !got.PKI.AllowUnsyncedClock { + t.Error("round-tripped AllowUnsyncedClock = false") + } +} + +func TestNTPResolverWarning(t *testing.T) { + cases := []struct { + name string + servers []string + nameservers []string + wantHost string + }{ + {"no servers", nil, nil, ""}, + {"literals need no resolver", []string{"10.0.0.123"}, nil, ""}, + {"hostname with a static resolver", []string{"time.example.org"}, []string{"10.0.0.53"}, ""}, + {"hostname without a static resolver", []string{"10.0.0.123", "time.example.org"}, nil, "time.example.org"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cfg.Network.NTPServers = tc.servers + cfg.Network.Nameservers = tc.nameservers + w := cfg.NTPResolverWarning() + if tc.wantHost == "" { + if w != "" { + t.Fatalf("NTPResolverWarning = %q, want none", w) + } + return + } + if !strings.Contains(w, tc.wantHost) || !strings.Contains(w, "network.nameservers") { + t.Fatalf("NTPResolverWarning = %q, want it to name %q and network.nameservers", w, tc.wantHost) + } + }) + } +} + +func TestTimeSourceWarning(t *testing.T) { + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if w := cfg.TimeSourceWarning(); !strings.Contains(w, "network.ntp_servers") { + t.Fatalf("TimeSourceWarning with no servers = %q, want it to name network.ntp_servers", w) + } + cfg.Network.NTPServers = []string{"10.0.0.123"} + if w := cfg.TimeSourceWarning(); w != "" { + t.Fatalf("TimeSourceWarning with servers = %q, want none", w) + } +} From 0f5f85e343a9da829c382937832450817e2e2b35 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:15:18 -0400 Subject: [PATCH 2/5] feat(timesync): add a client-only SNTPv4 with a clock floor Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- internal/timesync/clock_linux.go | 79 ++++ internal/timesync/clock_other.go | 39 ++ internal/timesync/doc.go | 23 + internal/timesync/engine.go | 695 ++++++++++++++++++++++++++++++ internal/timesync/engine_test.go | 698 +++++++++++++++++++++++++++++++ internal/timesync/floor.go | 118 ++++++ internal/timesync/floor_test.go | 104 +++++ internal/timesync/packet.go | 305 ++++++++++++++ internal/timesync/packet_test.go | 277 ++++++++++++ 9 files changed, 2338 insertions(+) create mode 100644 internal/timesync/clock_linux.go create mode 100644 internal/timesync/clock_other.go create mode 100644 internal/timesync/doc.go create mode 100644 internal/timesync/engine.go create mode 100644 internal/timesync/engine_test.go create mode 100644 internal/timesync/floor.go create mode 100644 internal/timesync/floor_test.go create mode 100644 internal/timesync/packet.go create mode 100644 internal/timesync/packet_test.go diff --git a/internal/timesync/clock_linux.go b/internal/timesync/clock_linux.go new file mode 100644 index 0000000..152955e --- /dev/null +++ b/internal/timesync/clock_linux.go @@ -0,0 +1,79 @@ +//go:build linux + +package timesync + +/* +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 ( + "fmt" + "time" + + "golang.org/x/sys/unix" +) + +// SystemClock returns the kernel real-time clock. +func SystemClock() Clock { return systemClock{} } + +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now() } + +// Step sets CLOCK_REALTIME to now plus d. +func (systemClock) Step(d time.Duration) error { + ts := unix.NsecToTimespec(time.Now().Add(d).UnixNano()) + if err := unix.ClockSettime(unix.CLOCK_REALTIME, &ts); err != nil { + return fmt.Errorf("clock_settime: %w", err) + } + return nil +} + +// Slew hands d to the kernel's adjtime path (ADJ_OFFSET_SINGLESHOT), which +// absorbs it at up to 500 ppm without moving the clock backwards. A new call +// replaces any adjustment still pending, which is what a fresh measurement +// wants. +func (systemClock) Slew(d time.Duration) error { + tx := unix.Timex{Modes: unix.ADJ_OFFSET_SINGLESHOT, Offset: d.Microseconds()} + if _, err := unix.Adjtimex(&tx); err != nil { + return fmt.Errorf("adjtimex(ADJ_OFFSET_SINGLESHOT): %w", err) + } + return nil +} + +// MarkSynced clears STA_UNSYNC and sets the error estimates. With STA_UNSYNC +// clear the kernel's 11-minute mode copies the system time to the hardware +// clock (CONFIG_RTC_SYSTOHC), so the next boot starts closer. The kernel sets +// STA_UNSYNC again by itself if the maximum error grows past its limit +// without another sync. +func (systemClock) MarkSynced(maxErr time.Duration) error { + var cur unix.Timex + if _, err := unix.Adjtimex(&cur); err != nil { + return fmt.Errorf("adjtimex(read): %w", err) + } + us := maxErr.Microseconds() + tx := unix.Timex{ + Modes: unix.ADJ_STATUS | unix.ADJ_MAXERROR | unix.ADJ_ESTERROR, + Status: cur.Status &^ unix.STA_UNSYNC, + Maxerror: us, + Esterror: us, + } + if _, err := unix.Adjtimex(&tx); err != nil { + return fmt.Errorf("adjtimex(clear STA_UNSYNC): %w", err) + } + return nil +} diff --git a/internal/timesync/clock_other.go b/internal/timesync/clock_other.go new file mode 100644 index 0000000..b80b326 --- /dev/null +++ b/internal/timesync/clock_other.go @@ -0,0 +1,39 @@ +//go:build !linux + +package timesync + +/* +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 ( + "errors" + "time" +) + +// SystemClock returns a clock that reads the host time but refuses to adjust +// it: only a CryptOS node (Linux) disciplines its clock. +func SystemClock() Clock { return otherClock{} } + +type otherClock struct{} + +var errUnsupported = errors.New("timesync: adjusting the clock is supported only on Linux") + +func (otherClock) Now() time.Time { return time.Now() } +func (otherClock) Step(time.Duration) error { return errUnsupported } +func (otherClock) Slew(time.Duration) error { return errUnsupported } +func (otherClock) MarkSynced(time.Duration) error { return errUnsupported } diff --git a/internal/timesync/doc.go b/internal/timesync/doc.go new file mode 100644 index 0000000..37215a8 --- /dev/null +++ b/internal/timesync/doc.go @@ -0,0 +1,23 @@ +// Package timesync is the node's client-only SNTPv4 (RFC 4330, and RFC 5905 +// section 14): a bounded sync at boot that steps or slews the clock, periodic +// polls after that, and a clock floor the clock is never stepped behind. It +// never serves time. +package timesync + +/* +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. +*/ diff --git a/internal/timesync/engine.go b/internal/timesync/engine.go new file mode 100644 index 0000000..306dbc7 --- /dev/null +++ b/internal/timesync/engine.go @@ -0,0 +1,695 @@ +package timesync + +/* +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 ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/netip" + "sort" + "strings" + "sync" + "time" + + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// Poll and adjustment limits. +const ( + // MaxServers matches network.ntp_servers and the kernel's option 42 cap. + MaxServers = 3 + // MinPoll is the steady-state poll interval (NTP MINPOLL, 2^6 s). + MinPoll = 64 * time.Second + // MaxPoll is the back-off ceiling while a server does not answer (NTP's + // default maximum, 2^10 s). + MaxPoll = 1024 * time.Second + // PollFloor is the RFC 4330 section 10 hard floor: a client must never + // poll one server more often than this. + PollFloor = 15 * time.Second + // StepThreshold is RFC 5905 STEPT: offsets above it are stepped, smaller + // ones slewed. + StepThreshold = 128 * time.Millisecond + + // bootAttempts is one request plus up to three retries per server at + // boot, the same burst as NTP's iburst. The spacing is a retransmission + // of an unanswered request, not the poll interval PollFloor governs. + bootAttempts = 4 + + defaultQueryTimeout = time.Second + defaultRetryGap = 2 * time.Second + // defaultBootBudget bounds the boot sync so an unreachable server never + // holds up a node that must stay manageable. + defaultBootBudget = 10 * time.Second +) + +// LevelTrace is below slog's Debug, for raw header fields. +const LevelTrace = slog.Level(-8) + +// Clock is the node's real-time clock. The system implementation steps with +// clock_settime and slews with adjtimex; tests use a fake so they never touch +// the host clock. +type Clock interface { + // Now reads the clock. Its value must carry a monotonic reading so the + // round-trip delay survives an adjustment mid-query. + Now() time.Time + // Step moves the clock by d at once. + Step(d time.Duration) error + // Slew asks the kernel to absorb d gradually. + Slew(d time.Duration) error + // MarkSynced tells the kernel the clock is synchronised to within + // maxErr, which lets it keep the hardware clock close. + MarkSynced(maxErr time.Duration) error +} + +// Config configures an Engine. +type Config struct { + // Servers are the time servers, at most MaxServers, each an IPv4 literal + // or a hostname, already validated. + Servers []string + // Source is where Servers came from. TIME_SOURCE_NONE (or no servers) + // makes the engine inert: NOT_CONFIGURED, never gating signing. + Source cryptosv1.TimeSource + Clock Clock + Floor *Floor + Logger *slog.Logger + + // Resolve looks up a hostname server's IPv4 addresses. Nil uses the + // system resolver, so a DNS change is picked up at the next poll. + Resolve func(ctx context.Context, host string) ([]netip.Addr, error) + // Dial opens a connected UDP socket to a server. Nil dials udp4; the + // connected socket makes the kernel drop datagrams from any other peer. + Dial func(ctx context.Context, addr netip.AddrPort) (net.Conn, error) + // Rand supplies the request nonce. Nil uses crypto/rand. + Rand io.Reader + + QueryTimeout time.Duration + RetryGap time.Duration + BootBudget time.Duration +} + +type server struct { + name string + interval time.Duration + nextDue time.Time + // disabled is the kiss code that stopped this server until reboot. + disabled string +} + +// Engine is the client-only SNTP synchroniser: one bounded sync at boot, then +// periodic polls. It never serves time. +type Engine struct { + cfg Config + log *slog.Logger + servers []*server + + // round serialises sync rounds. + round sync.Mutex + + mu sync.Mutex + state cryptosv1.TimeSyncState + syncedOnce bool + lastServer string + lastOffset time.Duration + hasOffset bool + stratum uint32 + lastSync time.Time + stepped bool + lastErr string +} + +// New builds an Engine. It does no I/O. +func New(cfg Config) (*Engine, error) { + if len(cfg.Servers) > MaxServers { + return nil, fmt.Errorf("timesync: at most %d servers, got %d", MaxServers, len(cfg.Servers)) + } + if cfg.Clock == nil { + return nil, errors.New("timesync: a clock is required") + } + if cfg.Floor == nil { + return nil, errors.New("timesync: a clock floor is required") + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + if cfg.Resolve == nil { + cfg.Resolve = func(ctx context.Context, host string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip4", host) + } + } + if cfg.Dial == nil { + cfg.Dial = func(ctx context.Context, addr netip.AddrPort) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp4", addr.String()) + } + } + if cfg.Rand == nil { + cfg.Rand = rand.Reader + } + if cfg.QueryTimeout <= 0 { + cfg.QueryTimeout = defaultQueryTimeout + } + if cfg.RetryGap <= 0 { + cfg.RetryGap = defaultRetryGap + } + if cfg.BootBudget <= 0 { + cfg.BootBudget = defaultBootBudget + } + if len(cfg.Servers) == 0 { + cfg.Source = cryptosv1.TimeSource_TIME_SOURCE_NONE + } + e := &Engine{cfg: cfg, log: cfg.Logger.With("component", "timesync")} + for _, s := range cfg.Servers { + e.servers = append(e.servers, &server{name: s, interval: MinPoll}) + } + if e.configured() { + e.state = cryptosv1.TimeSyncState_TIME_SYNC_STATE_PENDING + } else { + e.state = cryptosv1.TimeSyncState_TIME_SYNC_STATE_NOT_CONFIGURED + } + return e, nil +} + +func (e *Engine) configured() bool { + return e.cfg.Source != cryptosv1.TimeSource_TIME_SOURCE_NONE && len(e.servers) > 0 +} + +// SignAllowed reports whether the signing gate is open: always with no time +// source, otherwise once the clock has synced at least once this boot. A later +// failed poll does not close it again: the clock was corrected and now drifts +// only at the hardware rate, and the status line still reports the failure. +func (e *Engine) SignAllowed() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.state == cryptosv1.TimeSyncState_TIME_SYNC_STATE_NOT_CONFIGURED || e.syncedOnce +} + +// Status returns the current state for GetStatus. +func (e *Engine) Status() *cryptosv1.TimeSyncStatus { + e.mu.Lock() + defer e.mu.Unlock() + st := &cryptosv1.TimeSyncStatus{ + State: e.state, + Source: e.cfg.Source, + Servers: append([]string(nil), e.cfg.Servers...), + LastServer: e.lastServer, + Stratum: e.stratum, + SteppedAtBoot: e.stepped, + LastError: e.lastErr, + } + if e.hasOffset { + st.LastOffset = durationpb.New(e.lastOffset) + } + if !e.lastSync.IsZero() { + st.LastSync = timestamppb.New(e.lastSync) + } + return st +} + +// NoteIssued raises the clock floor to an issued certificate's notBefore, so +// no later sync can step the clock behind a certificate already handed out. +func (e *Engine) NoteIssued(notBefore time.Time) { + if err := e.cfg.Floor.Advance(notBefore); err != nil { + e.log.Error("clock floor: could not record an issued notBefore", "not_before", notBefore, "err", err) + return + } + e.log.Log(context.Background(), LevelTrace, "clock floor raised to an issued notBefore", "not_before", notBefore) +} + +// Shutdown records the clock in the floor at a clean shutdown, but only if it +// synced this boot: an unsynced clock may be fast, and baking a fast time into +// the floor would block the correction on the next boot. +func (e *Engine) Shutdown() { + e.mu.Lock() + synced := e.syncedOnce + e.mu.Unlock() + if !synced { + e.log.Debug("clock floor: not recorded at shutdown, the clock never synced this boot") + return + } + now := e.cfg.Clock.Now() + if err := e.cfg.Floor.Advance(now); err != nil { + e.log.Error("clock floor: could not record the clock at shutdown", "err", err) + return + } + e.log.Info("clock floor recorded at shutdown", "floor", now.UTC()) +} + +// BootSync runs the bounded boot sync: one request per server, retried up to +// three times while unanswered, within the boot budget. It steps the clock +// when the offset exceeds StepThreshold and slews otherwise. It never fails: +// a node that cannot reach its servers boots on its hardware clock, with +// signing gated until a later poll succeeds. +func (e *Engine) BootSync(ctx context.Context) { + if !e.configured() { + e.log.Info("no time source configured; running on the hardware clock, signing is not gated") + return + } + e.round.Lock() + defer e.round.Unlock() + start := time.Now() + e.log.Info("boot time sync starting", "source", e.cfg.Source.String(), "servers", strings.Join(e.cfg.Servers, ","), "floor", e.cfg.Floor.Get()) + ctx, cancel := context.WithTimeout(ctx, e.cfg.BootBudget) + defer cancel() + + pending := e.enabled() + var samples []Sample + errs := map[string]error{} + for attempt := 0; attempt < bootAttempts && len(pending) > 0; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + case <-time.After(e.cfg.RetryGap): + } + if ctx.Err() != nil { + break + } + e.log.Debug("boot time sync retrying unanswered servers", "attempt", attempt+1, "servers", names(pending)) + } + var retry []*server + for i, r := range e.queryAll(ctx, pending) { + srv := pending[i] + if r.err == nil { + samples = append(samples, r.sample) + delete(errs, srv.name) + continue + } + errs[srv.name] = r.err + if e.handleKiss(srv, r.err) { + continue + } + retry = append(retry, srv) + } + pending = retry + } + now := time.Now() + for _, s := range e.servers { + s.nextDue = now.Add(s.interval) + } + e.apply(samples, errs, true) + e.log.Debug("boot time sync finished", "duration", time.Since(start), "replies", len(samples)) +} + +// Run polls until ctx is done, waking when the next server is due. +func (e *Engine) Run(ctx context.Context) { + if !e.configured() { + return + } + e.log.Debug("periodic time sync started") + for { + wait := e.untilNextDue() + if wait < 0 { + e.log.Warn("periodic time sync stopped: every server refused service") + return + } + t := time.NewTimer(wait) + select { + case <-ctx.Done(): + t.Stop() + e.log.Debug("periodic time sync stopped") + return + case <-t.C: + } + e.sync(ctx, false) + } +} + +// untilNextDue is how long until the earliest enabled server is due, never +// less than PollFloor, or -1 when no server is enabled. +func (e *Engine) untilNextDue() time.Duration { + e.round.Lock() + defer e.round.Unlock() + var next time.Time + for _, s := range e.servers { + if s.disabled != "" { + continue + } + if next.IsZero() || s.nextDue.Before(next) { + next = s.nextDue + } + } + if next.IsZero() { + return -1 + } + return max(time.Until(next), PollFloor) +} + +// sync runs one periodic round over the servers that are due (all enabled +// ones when force is set). +func (e *Engine) sync(ctx context.Context, force bool) { + e.round.Lock() + defer e.round.Unlock() + now := time.Now() + var due []*server + for _, s := range e.enabled() { + if force || !now.Before(s.nextDue) { + due = append(due, s) + } + } + if len(due) == 0 { + e.log.Log(ctx, LevelTrace, "time sync round skipped: no server is due") + return + } + var samples []Sample + errs := map[string]error{} + for i, r := range e.queryAll(ctx, due) { + srv := due[i] + if r.err == nil { + samples = append(samples, r.sample) + srv.interval = MinPoll + } else { + errs[srv.name] = r.err + if !e.handleKiss(srv, r.err) { + srv.interval = backoff(srv.interval) + e.log.Debug("time server did not answer; backing off", "server", srv.name, "interval", srv.interval) + } + } + srv.nextDue = time.Now().Add(srv.interval) + } + e.apply(samples, errs, false) +} + +// handleKiss acts on a Kiss-o'-Death and reports whether err was one the +// client must not retry (RFC 5905 section 7.4). DENY and RSTR stop queries to +// that server until reboot; RATE doubles its poll interval. Other codes are +// treated as no reply. +func (e *Engine) handleKiss(srv *server, err error) bool { + var kiss *KissError + if !errors.As(err, &kiss) { + return false + } + switch kiss.Code { + case KissDeny, KissRstr: + srv.disabled = kiss.Code + e.log.Warn("time server sent kiss-o'-death; not querying it again until reboot", "server", srv.name, "code", kiss.Code) + return true + case KissRate: + srv.interval = backoff(srv.interval) + e.log.Warn("time server sent kiss-o'-death RATE; reducing the poll rate", "server", srv.name, "interval", srv.interval) + return true + } + e.log.Warn("time server sent a kiss-o'-death; treating it as no reply", "server", srv.name, "code", kiss.Code) + return false +} + +func backoff(d time.Duration) time.Duration { + return min(max(2*d, PollFloor), MaxPoll) +} + +func (e *Engine) enabled() []*server { + var out []*server + for _, s := range e.servers { + if s.disabled == "" { + out = append(out, s) + } + } + return out +} + +type result struct { + sample Sample + err error +} + +// queryAll queries servers concurrently so one slow server cannot use up the +// others' share of the boot budget. +func (e *Engine) queryAll(ctx context.Context, servers []*server) []result { + out := make([]result, len(servers)) + var wg sync.WaitGroup + for i, s := range servers { + wg.Add(1) + go func() { + defer wg.Done() + sample, err := e.query(ctx, s.name) + if err != nil { + e.log.Warn("time server reply rejected or missing", "server", s.name, "err", err) + } + out[i] = result{sample: sample, err: err} + }() + } + wg.Wait() + return out +} + +// query performs one client/server exchange with name. +func (e *Engine) query(ctx context.Context, name string) (Sample, error) { + start := time.Now() + addr, err := e.resolve(ctx, name) + if err != nil { + return Sample{}, err + } + ap := netip.AddrPortFrom(addr, Port) + conn, err := e.cfg.Dial(ctx, ap) + if err != nil { + return Sample{}, fmt.Errorf("timesync: %s: dial %s: %w", name, ap, err) + } + defer func() { _ = conn.Close() }() + + var nb [8]byte + if _, err := io.ReadFull(e.cfg.Rand, nb[:]); err != nil { + return Sample{}, fmt.Errorf("timesync: request nonce: %w", err) + } + nonce := Timestamp(binary.BigEndian.Uint64(nb[:])) + if nonce == 0 { + nonce = 1 + } + deadline := time.Now().Add(e.cfg.QueryTimeout) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := conn.SetDeadline(deadline); err != nil { + return Sample{}, fmt.Errorf("timesync: %s: %w", name, err) + } + stop := context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Now()) }) + defer stop() + + pivot := e.pivot() + t1 := e.cfg.Clock.Now() + if _, err := conn.Write(NewRequest(nonce).Marshal()); err != nil { + return Sample{}, fmt.Errorf("timesync: %s: send: %w", name, err) + } + e.log.Log(ctx, LevelTrace, "time request sent", "server", name, "addr", ap.String()) + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + t4 := e.cfg.Clock.Now() + if err != nil { + return Sample{}, fmt.Errorf("timesync: %s: no reply: %w", name, err) + } + h, err := ParseHeader(buf[:n]) + if err != nil { + e.log.Warn("time reply discarded", "server", name, "err", err) + continue + } + e.log.Log(ctx, LevelTrace, "time reply header", "server", name, + "li", h.Leap, "vn", h.Version, "mode", h.Mode, "stratum", h.Stratum, "poll", h.Poll, + "precision", h.Precision, "root_delay", h.RootDelay.Duration(), "root_dispersion", h.RootDispersion.Duration(), + "ref_id", fmt.Sprintf("%x", h.ReferenceID), "origin_matches", h.Origin == nonce) + // A reply that does not echo this request's nonce may be a late + // answer to an earlier one; keep waiting for ours. + if h.Mode == ModeServer && h.Origin != nonce { + e.log.Warn("time reply discarded: origin does not match this request", "server", name) + continue + } + if err := ValidateReply(h, nonce); err != nil { + return Sample{}, fmt.Errorf("%w (server %s)", err, name) + } + offset, delay, err := Compute(t1, t4, h, pivot) + if err != nil { + return Sample{}, fmt.Errorf("%w (server %s)", err, name) + } + s := Sample{Server: name, Addr: ap.String(), Offset: offset, Delay: delay, Stratum: h.Stratum, RootDistance: RootDistance(h)} + e.log.Debug("time poll", "server", name, "addr", ap.String(), "offset", offset, "delay", delay, + "stratum", h.Stratum, "duration", time.Since(start)) + return s, nil + } +} + +func (e *Engine) resolve(ctx context.Context, name string) (netip.Addr, error) { + if a, err := netip.ParseAddr(name); err == nil { + return a, nil + } + addrs, err := e.cfg.Resolve(ctx, name) + if err != nil { + return netip.Addr{}, fmt.Errorf("timesync: resolve %s: %w", name, err) + } + for _, a := range addrs { + if a.Is4() || a.Is4In6() { + a = a.Unmap() + e.log.Log(ctx, LevelTrace, "time server resolved", "server", name, "addr", a.String()) + return a, nil + } + } + return netip.Addr{}, fmt.Errorf("timesync: resolve %s: no IPv4 address", name) +} + +// pivot is the time the era of a reply is decoded around: the clock, or the +// floor when the clock is behind it (a hardware clock reset to its epoch). +func (e *Engine) pivot() time.Time { + now := e.cfg.Clock.Now() + if f := e.cfg.Floor.Get(); now.Before(f) { + return f + } + return now +} + +// apply combines one round and adjusts the clock. +func (e *Engine) apply(samples []Sample, errs map[string]error, boot bool) { + if len(samples) == 0 { + e.fail(joinErrs(errs), slog.LevelWarn) + return + } + s, err := Combine(samples) + if err != nil { + e.fail(err.Error(), slog.LevelWarn) + return + } + abs := absDuration(s.Offset) + var step bool + switch { + case abs <= StepThreshold: + case boot || s.Offset > 0: + step = true + default: + // Stepping a live CA backwards would reorder audit and issuance + // times. Refuse, and leave the clock for the operator to look at. + e.log.Error("refused a backwards clock step on a running node", "server", s.Server, "offset", s.Offset) + e.fail(fmt.Sprintf("refused a backwards step of %v on a running node (server %s)", -s.Offset, s.Server), slog.LevelError) + return + } + now := e.cfg.Clock.Now() + if step { + // Only a step can move the clock backwards; a slew only slows it, + // so the floor is checked for steps. + target := now.Add(s.Offset) + if floor := e.cfg.Floor.Get(); target.Before(floor) { + msg := fmtFloorError(target, floor) + e.log.Error("clock step refused: "+msg, "server", s.Server, "offset", s.Offset, "delta", floor.Sub(target)) + e.fail(msg+" (server "+s.Server+")", slog.LevelError) + return + } + if err := e.cfg.Clock.Step(s.Offset); err != nil { + e.log.Error("clock step failed", "offset", s.Offset, "err", err) + e.fail(fmt.Sprintf("set clock: %v", err), slog.LevelError) + return + } + e.log.Info("clock stepped", "server", s.Server, "offset", s.Offset, "boot", boot) + } else { + if err := e.cfg.Clock.Slew(s.Offset); err != nil { + e.log.Error("clock slew failed", "offset", s.Offset, "err", err) + e.fail(fmt.Sprintf("slew clock: %v", err), slog.LevelError) + return + } + e.log.Debug("clock slewing", "server", s.Server, "offset", s.Offset) + } + if err := e.cfg.Clock.MarkSynced(s.RootDistance + s.Delay/2); err != nil { + e.log.Warn("could not mark the kernel clock synchronised", "err", err) + } + synced := e.cfg.Clock.Now() + if err := e.cfg.Floor.Advance(synced); err != nil { + e.log.Error("clock floor: could not record the synced time", "err", err) + } + e.succeed(s, synced, boot && step) +} + +func (e *Engine) fail(msg string, level slog.Level) { + e.mu.Lock() + prev := e.state + e.state = cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED + e.lastErr = msg + e.mu.Unlock() + if prev != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED { + e.log.Info("time sync state changed", "from", prev.String(), "to", "UNSYNCED") + } + e.log.Log(context.Background(), level, "time sync round did not adjust the clock", "reason", msg) +} + +func (e *Engine) succeed(s Sample, at time.Time, stepped bool) { + e.mu.Lock() + prev := e.state + first := !e.syncedOnce + e.state = cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED + e.syncedOnce = true + e.lastServer = s.Server + e.lastOffset = s.Offset + e.hasOffset = true + e.stratum = uint32(s.Stratum) + e.lastSync = at + e.lastErr = "" + if stepped { + e.stepped = true + } + e.mu.Unlock() + if prev != cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED { + e.log.Info("time sync state changed", "from", prev.String(), "to", "SYNCED", "server", s.Server, "offset", s.Offset) + } + if first { + e.log.Info("clock synced for the first time this boot; the signing gate is open", "server", s.Server) + } +} + +// fmtFloorError is the reason a step below the floor was refused. +func fmtFloorError(target, floor time.Time) string { + return fmt.Sprintf("server time %s is behind already-issued certificates or the last good sync (floor %s)", + target.UTC().Format(time.RFC3339Nano), floor.UTC().Format(time.RFC3339Nano)) +} + +func joinErrs(errs map[string]error) string { + if len(errs) == 0 { + return "no server answered" + } + keys := make([]string, 0, len(errs)) + for k := range errs { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, errs[k].Error()) + } + return strings.Join(parts, "; ") +} + +func names(servers []*server) string { + n := make([]string, 0, len(servers)) + for _, s := range servers { + n = append(n, s.name) + } + return strings.Join(n, ",") +} + +// interval reports a server's poll interval, for tests. +func (e *Engine) interval(name string) time.Duration { + e.round.Lock() + defer e.round.Unlock() + for _, s := range e.servers { + if s.name == name { + return s.interval + } + } + return 0 +} diff --git a/internal/timesync/engine_test.go b/internal/timesync/engine_test.go new file mode 100644 index 0000000..ea79695 --- /dev/null +++ b/internal/timesync/engine_test.go @@ -0,0 +1,698 @@ +package timesync + +/* +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" + "context" + "errors" + "log/slog" + "net" + "net/netip" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// fakeClock is the node clock under test: it starts at base, advances with +// real elapsed time, and records every adjustment instead of making it. +type fakeClock struct { + mu sync.Mutex + base time.Time + start time.Time + steps []time.Duration + slews []time.Duration + synced int + failAll error +} + +func newFakeClock(base time.Time) *fakeClock { + return &fakeClock{base: base, start: time.Now()} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.base.Add(time.Since(c.start)) +} + +func (c *fakeClock) Step(d time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.failAll != nil { + return c.failAll + } + c.steps = append(c.steps, d) + c.base = c.base.Add(d) + return nil +} + +func (c *fakeClock) Slew(d time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.failAll != nil { + return c.failAll + } + c.slews = append(c.slews, d) + return nil +} + +func (c *fakeClock) MarkSynced(time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + c.synced++ + return nil +} + +func (c *fakeClock) adjustments() (steps, slews []time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]time.Duration(nil), c.steps...), append([]time.Duration(nil), c.slews...) +} + +// fakeServer is an SNTP server on loopback. Its time is the fake clock plus +// offset, so the offset the client measures is known. +type fakeServer struct { + conn *net.UDPConn + clock *fakeClock + requests atomic.Int32 + + mu sync.Mutex + offset time.Duration + stratum uint8 + leap uint8 + kiss string + drop bool +} + +func newFakeServer(t *testing.T, clock *fakeClock, offset time.Duration) *fakeServer { + t.Helper() + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("listen: %v", err) + } + s := &fakeServer{conn: conn, clock: clock, offset: offset, stratum: 2} + t.Cleanup(func() { _ = conn.Close() }) + go s.serve() + return s +} + +func (s *fakeServer) set(fn func(*fakeServer)) { + s.mu.Lock() + defer s.mu.Unlock() + fn(s) +} + +func (s *fakeServer) serve() { + buf := make([]byte, 512) + for { + n, from, err := s.conn.ReadFromUDP(buf) + if err != nil { + return + } + s.requests.Add(1) + req, err := ParseHeader(buf[:n]) + if err != nil { + continue + } + s.mu.Lock() + drop, off, stratum, leap, kiss := s.drop, s.offset, s.stratum, s.leap, s.kiss + s.mu.Unlock() + if drop { + continue + } + now := TimestampFromTime(s.clock.Now().Add(off)) + reply := Header{ + Leap: leap, Version: 4, Mode: ModeServer, Stratum: stratum, + RootDelay: 0x00000100, RootDispersion: 0x00000100, + Origin: req.Transmit, Receive: now, Transmit: now, + } + if kiss != "" { + reply.Stratum = 0 + reply.Leap = 3 + copy(reply.ReferenceID[:], kiss) + } + _, _ = s.conn.WriteToUDP(reply.Marshal(), from) + } +} + +func (s *fakeServer) addr() netip.AddrPort { + return s.conn.LocalAddr().(*net.UDPAddr).AddrPort() +} + +// harness wires an Engine to fake servers. Each server is reached under a +// documentation address (192.0.2.0/24), which Dial maps to the loopback port. +type harness struct { + t *testing.T + clock *fakeClock + floor *Floor + servers map[netip.Addr]*fakeServer + names map[string]netip.Addr + logs *bytes.Buffer + cfg Config +} + +var testNow = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + +func newHarness(t *testing.T) *harness { + t.Helper() + floor, err := OpenFloor(filepath.Join(t.TempDir(), "clock-floor"), testNow.Add(-30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + h := &harness{ + t: t, clock: newFakeClock(testNow), floor: floor, + servers: map[netip.Addr]*fakeServer{}, names: map[string]netip.Addr{}, + logs: &bytes.Buffer{}, + } + h.cfg = Config{ + Source: cryptosv1.TimeSource_TIME_SOURCE_MACHINE_CONFIG, + Clock: h.clock, + Floor: floor, + Logger: slog.New(slog.NewTextHandler(&syncWriter{b: h.logs}, &slog.HandlerOptions{Level: LevelTrace})), + QueryTimeout: 200 * time.Millisecond, + RetryGap: 20 * time.Millisecond, + BootBudget: 2 * time.Second, + Resolve: func(_ context.Context, host string) ([]netip.Addr, error) { + if a, ok := h.names[host]; ok { + return []netip.Addr{a}, nil + } + return nil, errors.New("no such host") + }, + Dial: func(ctx context.Context, addr netip.AddrPort) (net.Conn, error) { + s, ok := h.servers[addr.Addr()] + if !ok || addr.Port() != Port { + return nil, errors.New("unexpected dial to " + addr.String()) + } + var d net.Dialer + return d.DialContext(ctx, "udp4", s.addr().String()) + }, + } + return h +} + +// add registers a fake server as name (a literal in 192.0.2.0/24 or a +// hostname) and returns it. +func (h *harness) add(name string, offset time.Duration) *fakeServer { + s := newFakeServer(h.t, h.clock, offset) + a, err := netip.ParseAddr(name) + if err != nil { + a = netip.AddrFrom4([4]byte{192, 0, 2, byte(100 + len(h.names))}) + h.names[name] = a + } + h.servers[a] = s + h.cfg.Servers = append(h.cfg.Servers, name) + return s +} + +func (h *harness) engine() *Engine { + h.t.Helper() + e, err := New(h.cfg) + if err != nil { + h.t.Fatalf("New: %v", err) + } + return e +} + +type syncWriter struct { + mu sync.Mutex + b *bytes.Buffer +} + +func (w *syncWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.b.Write(p) +} + +func approx(got, want time.Duration) bool { + d := got - want + return d > -50*time.Millisecond && d < 50*time.Millisecond +} + +func TestBootSyncStepsALargeOffset(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 5*time.Second) + e := h.engine() + if e.SignAllowed() { + t.Fatal("SignAllowed before the first sync with a configured source") + } + if got := e.Status().GetState(); got != cryptosv1.TimeSyncState_TIME_SYNC_STATE_PENDING { + t.Fatalf("state before sync = %v, want PENDING", got) + } + e.BootSync(context.Background()) + + steps, slews := h.clock.adjustments() + if len(steps) != 1 || !approx(steps[0], 5*time.Second) || len(slews) != 0 { + t.Fatalf("steps = %v, slews = %v; want one step of about 5s", steps, slews) + } + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED || !st.GetSteppedAtBoot() { + t.Fatalf("status = %v", st) + } + if st.GetLastServer() != "192.0.2.1" || st.GetStratum() != 2 || st.GetLastSync() == nil { + t.Fatalf("status = %v", st) + } + if !approx(st.GetLastOffset().AsDuration(), 5*time.Second) { + t.Fatalf("last offset = %v", st.GetLastOffset().AsDuration()) + } + if !e.SignAllowed() { + t.Fatal("SignAllowed = false after a good sync") + } + if h.clock.synced != 1 { + t.Fatalf("MarkSynced called %d times, want 1", h.clock.synced) + } + if !h.floor.Get().After(testNow.Add(4 * time.Second)) { + t.Fatalf("floor = %v, want it advanced to the synced time", h.floor.Get()) + } + if !strings.Contains(h.logs.String(), "stepped") { + t.Fatalf("no info log for the boot step:\n%s", h.logs) + } +} + +func TestBootSyncSlewsASmallOffset(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 60*time.Millisecond) + e := h.engine() + e.BootSync(context.Background()) + steps, slews := h.clock.adjustments() + if len(steps) != 0 || len(slews) != 1 { + t.Fatalf("steps = %v, slews = %v; want one slew", steps, slews) + } + if e.Status().GetSteppedAtBoot() { + t.Fatal("stepped_at_boot set for a slew") + } +} + +// A node with no time source runs on its hardware clock and is never gated. +func TestNoSourceIsNotConfiguredAndNeverGated(t *testing.T) { + h := newHarness(t) + h.cfg.Source = cryptosv1.TimeSource_TIME_SOURCE_NONE + e := h.engine() + e.BootSync(context.Background()) + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_NOT_CONFIGURED || st.GetSource() != cryptosv1.TimeSource_TIME_SOURCE_NONE { + t.Fatalf("status = %v", st) + } + if !e.SignAllowed() { + t.Fatal("SignAllowed = false with no time source") + } +} + +func TestBootSyncUnreachableServerIsBoundedAndGated(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + s.set(func(s *fakeServer) { s.drop = true }) + h.cfg.BootBudget = 500 * time.Millisecond + e := h.engine() + start := time.Now() + e.BootSync(context.Background()) + if el := time.Since(start); el > time.Second { + t.Fatalf("BootSync took %v, want it bounded by the boot budget", el) + } + if got := s.requests.Load(); got < 2 || got > 4 { + t.Fatalf("server saw %d requests, want the first try plus retries, at most 4", got) + } + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || st.GetLastError() == "" { + t.Fatalf("status = %v", st) + } + if e.SignAllowed() { + t.Fatal("SignAllowed with a configured source that never synced") + } +} + +func TestDisagreeingSourcesAreNotApplied(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 0) + h.add("192.0.2.2", 5*time.Second) + e := h.engine() + e.BootSync(context.Background()) + steps, slews := h.clock.adjustments() + if len(steps)+len(slews) != 0 { + t.Fatalf("clock adjusted (steps %v, slews %v) although the sources disagree", steps, slews) + } + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || !strings.Contains(st.GetLastError(), "disagree") { + t.Fatalf("status = %v", st) + } + if !strings.Contains(h.logs.String(), "level=WARN") { + t.Fatalf("no warn log for the disagreement:\n%s", h.logs) + } +} + +func TestMedianOfThreeIsApplied(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 2300*time.Millisecond) + h.add("192.0.2.2", 2000*time.Millisecond) + h.add("192.0.2.3", 2100*time.Millisecond) + e := h.engine() + e.BootSync(context.Background()) + steps, _ := h.clock.adjustments() + if len(steps) != 1 || !approx(steps[0], 2100*time.Millisecond) { + t.Fatalf("steps = %v, want the median 2.1s", steps) + } + if got := e.Status().GetLastServer(); got != "192.0.2.3" { + t.Fatalf("last server = %q, want the median's server", got) + } +} + +// A server's time behind the floor (a notBefore already issued) is never +// applied. +func TestTimeBehindTheFloorIsRefused(t *testing.T) { + h := newHarness(t) + if err := h.floor.Advance(testNow.Add(time.Hour)); err != nil { + t.Fatal(err) + } + h.add("192.0.2.1", 10*time.Second) + e := h.engine() + e.BootSync(context.Background()) + steps, slews := h.clock.adjustments() + if len(steps)+len(slews) != 0 { + t.Fatalf("clock adjusted behind the floor: steps %v, slews %v", steps, slews) + } + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || !strings.Contains(st.GetLastError(), "behind") { + t.Fatalf("status = %v", st) + } + if e.SignAllowed() { + t.Fatal("SignAllowed after the only sync was refused") + } + if !strings.Contains(h.logs.String(), "level=ERROR") { + t.Fatalf("no error log for the floor violation:\n%s", h.logs) + } +} + +func TestPeriodicSyncSlewsStepsForwardAndRefusesBackwards(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 10*time.Millisecond) + e := h.engine() + e.BootSync(context.Background()) + if !e.SignAllowed() { + t.Fatal("boot sync failed") + } + + s.set(func(s *fakeServer) { s.offset = 40 * time.Millisecond }) + e.sync(context.Background(), true) + _, slews := h.clock.adjustments() + if len(slews) != 2 { + t.Fatalf("slews = %v, want a second slew", slews) + } + + s.set(func(s *fakeServer) { s.offset = 2 * time.Second }) + e.sync(context.Background(), true) + steps, _ := h.clock.adjustments() + if len(steps) != 1 || !approx(steps[0], 2*time.Second) { + t.Fatalf("steps = %v, want one forward step of 2s", steps) + } + + s.set(func(s *fakeServer) { s.offset = -2 * time.Second }) + e.sync(context.Background(), true) + steps, _ = h.clock.adjustments() + if len(steps) != 1 { + t.Fatalf("steps = %v, want the backwards step refused", steps) + } + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || !strings.Contains(st.GetLastError(), "backwards") { + t.Fatalf("status = %v", st) + } + // The gate is "never synced this boot": a later refusal does not stop + // signing on a clock that was already corrected. + if !e.SignAllowed() { + t.Fatal("SignAllowed = false after a later round failed") + } +} + +func TestKissDenyStopsQueryingThatServer(t *testing.T) { + for _, code := range []string{KissDeny, KissRstr} { + t.Run(code, func(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + s.set(func(s *fakeServer) { s.kiss = code }) + e := h.engine() + e.BootSync(context.Background()) + if got := s.requests.Load(); got != 1 { + t.Fatalf("server saw %d requests at boot, want 1 (no retry after %s)", got, code) + } + e.sync(context.Background(), true) + if got := s.requests.Load(); got != 1 { + t.Fatalf("server saw %d requests, want none after %s", got, code) + } + if !strings.Contains(e.Status().GetLastError(), code) { + t.Fatalf("last error = %q, want it to name %s", e.Status().GetLastError(), code) + } + }) + } +} + +func TestKissRateDoublesThePollInterval(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + e := h.engine() + e.BootSync(context.Background()) + if got := e.interval("192.0.2.1"); got != MinPoll { + t.Fatalf("interval after a good sync = %v, want %v", got, MinPoll) + } + s.set(func(s *fakeServer) { s.kiss = KissRate }) + e.sync(context.Background(), true) + if got := e.interval("192.0.2.1"); got != 2*MinPoll { + t.Fatalf("interval after RATE = %v, want %v", got, 2*MinPoll) + } +} + +func TestUnreachableServerBacksOffToMaxPoll(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + s.set(func(s *fakeServer) { s.drop = true }) + h.cfg.BootBudget = 100 * time.Millisecond + h.cfg.QueryTimeout = 20 * time.Millisecond + e := h.engine() + e.BootSync(context.Background()) + var seen []time.Duration + for range 8 { + e.sync(context.Background(), true) + seen = append(seen, e.interval("192.0.2.1")) + } + for i, d := range seen { + if d < PollFloor { + t.Fatalf("interval %d = %v, below the RFC 4330 floor of %v", i, d, PollFloor) + } + if d > MaxPoll { + t.Fatalf("interval %d = %v, above MaxPoll", i, d) + } + } + if seen[len(seen)-1] != MaxPoll { + t.Fatalf("intervals = %v, want the back-off to reach %v", seen, MaxPoll) + } +} + +// Only servers whose poll interval has elapsed are queried in a round. +func TestPeriodicSyncHonoursThePollInterval(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + e := h.engine() + e.BootSync(context.Background()) + before := s.requests.Load() + e.sync(context.Background(), false) + if got := s.requests.Load(); got != before { + t.Fatalf("server queried again %d times before its poll interval elapsed", got-before) + } +} + +func TestHostnameServerIsResolvedAndReportedByName(t *testing.T) { + h := newHarness(t) + h.add("time.example.org", 3*time.Second) + e := h.engine() + e.BootSync(context.Background()) + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED || st.GetLastServer() != "time.example.org" { + t.Fatalf("status = %v", st) + } +} + +func TestUnresolvableHostnameIsReported(t *testing.T) { + h := newHarness(t) + h.cfg.Servers = []string{"missing.example.org"} + e := h.engine() + e.BootSync(context.Background()) + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || !strings.Contains(st.GetLastError(), "missing.example.org") { + t.Fatalf("status = %v", st) + } +} + +func TestClockSyscallFailureIsReported(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 5*time.Second) + h.clock.failAll = errors.New("operation not permitted") + e := h.engine() + e.BootSync(context.Background()) + st := e.Status() + if st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_UNSYNCED || !strings.Contains(st.GetLastError(), "not permitted") { + t.Fatalf("status = %v", st) + } + if e.SignAllowed() { + t.Fatal("SignAllowed although the clock could not be set") + } +} + +// A reply from the wrong peer never reaches the client: the socket is +// connected, so the kernel drops it, and the query times out. +func TestReplyFromAnotherAddressIsIgnored(t *testing.T) { + h := newHarness(t) + target, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = target.Close() }() + spoof, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = spoof.Close() }() + go func() { + buf := make([]byte, 512) + for { + n, from, err := target.ReadFromUDP(buf) + if err != nil { + return + } + req, err := ParseHeader(buf[:n]) + if err != nil { + continue + } + now := TimestampFromTime(h.clock.Now().Add(time.Hour)) + reply := Header{Version: 4, Mode: ModeServer, Stratum: 1, Origin: req.Transmit, Receive: now, Transmit: now} + _, _ = spoof.WriteToUDP(reply.Marshal(), from) + } + }() + h.cfg.Servers = []string{"192.0.2.1"} + h.cfg.BootBudget = 300 * time.Millisecond + h.cfg.Dial = func(ctx context.Context, _ netip.AddrPort) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp4", target.LocalAddr().String()) + } + e := h.engine() + e.BootSync(context.Background()) + if steps, _ := h.clock.adjustments(); len(steps) != 0 { + t.Fatalf("clock stepped by a spoofed reply: %v", steps) + } + if e.SignAllowed() { + t.Fatal("SignAllowed after only a spoofed reply") + } +} + +func TestNewRejectsTooManyServers(t *testing.T) { + h := newHarness(t) + h.cfg.Servers = []string{"192.0.2.1", "192.0.2.2", "192.0.2.3", "192.0.2.4"} + if _, err := New(h.cfg); err == nil { + t.Fatal("New accepted four servers") + } +} + +func TestRunStopsWithTheContext(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 0) + e := h.engine() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { e.Run(ctx); close(done) }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after the context was cancelled") + } +} + +func TestNoteIssuedRaisesTheFloor(t *testing.T) { + h := newHarness(t) + e := h.engine() + nb := testNow.Add(2 * time.Hour) + e.NoteIssued(nb) + if got := h.floor.Get(); !got.Equal(nb) { + t.Fatalf("floor = %v, want the issued notBefore %v", got, nb) + } +} + +// At shutdown the floor records the clock only if it was synced this boot; an +// unsynced clock may be fast, and baking it into the floor would block the +// correction on the next boot. +func TestShutdownRecordsOnlyASyncedClock(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + s.set(func(s *fakeServer) { s.drop = true }) + h.cfg.BootBudget = 100 * time.Millisecond + e := h.engine() + e.BootSync(context.Background()) + before := h.floor.Get() + e.Shutdown() + if !h.floor.Get().Equal(before) { + t.Fatal("Shutdown advanced the floor on an unsynced clock") + } + + s.set(func(s *fakeServer) { s.drop = false }) + e.sync(context.Background(), true) + if !e.SignAllowed() { + t.Fatal("sync failed") + } + mark := h.floor.Get() + time.Sleep(5 * time.Millisecond) + e.Shutdown() + if !h.floor.Get().After(mark) { + t.Fatal("Shutdown did not advance the floor on a synced clock") + } +} + +func TestStatusListsServersAndSource(t *testing.T) { + h := newHarness(t) + h.add("192.0.2.1", 0) + h.add("time.example.org", 0) + h.cfg.Source = cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE + e := h.engine() + st := e.Status() + if st.GetSource() != cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE || strings.Join(st.GetServers(), ",") != "192.0.2.1,time.example.org" { + t.Fatalf("status = %v", st) + } +} + +// A slew only slows the clock, so it never reorders issued times: a small +// negative offset measured just after an issuance is slewed, not refused by +// the floor. +func TestSmallNegativeSlewIsNotBlockedByTheFloor(t *testing.T) { + h := newHarness(t) + s := h.add("192.0.2.1", 0) + e := h.engine() + e.BootSync(context.Background()) + e.NoteIssued(h.clock.Now()) + s.set(func(s *fakeServer) { s.offset = -80 * time.Millisecond }) + e.sync(context.Background(), true) + _, slews := h.clock.adjustments() + if len(slews) != 2 || !approx(slews[1], -80*time.Millisecond) { + t.Fatalf("slews = %v, want a second slew of about -80ms", slews) + } + if st := e.Status(); st.GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED { + t.Fatalf("status = %v", st) + } +} diff --git a/internal/timesync/floor.go b/internal/timesync/floor.go new file mode 100644 index 0000000..7ed239a --- /dev/null +++ b/internal/timesync/floor.go @@ -0,0 +1,118 @@ +package timesync + +/* +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 ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Floor is the earliest time the node's clock may be set to: the latest of +// the image build time, the last good sync, and the latest notBefore the node +// has issued. Setting the clock behind it would reorder audit and issuance +// times on a CA that has already used later ones. It persists in a small file +// on the encrypted state volume, which is mounted before networking, so the +// boot sync can read it before etcd starts. +type Floor struct { + path string + build time.Time + + mu sync.Mutex + cur time.Time +} + +// OpenFloor loads the floor file at path, raised to build. A missing file is a +// first boot and leaves the floor at build. A file that cannot be parsed is +// returned as an error alongside a usable Floor at build, so the caller can +// log it loudly and keep going; the next Advance rewrites the file. +func OpenFloor(path string, build time.Time) (*Floor, error) { + f := &Floor{path: path, build: build.UTC(), cur: build.UTC()} + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return f, nil + } + if err != nil { + return f, fmt.Errorf("timesync: read clock floor %s: %w", path, err) + } + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(raw))) + if err != nil { + return f, fmt.Errorf("timesync: clock floor %s is damaged: %w", path, err) + } + if t.After(f.cur) { + f.cur = t.UTC() + } + return f, nil +} + +// Get returns the current floor. +func (f *Floor) Get() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.cur +} + +// Advance raises the floor to t and persists it. A t at or below the floor is +// a no-op, so callers may pass every issuance time without checking. +func (f *Floor) Advance(t time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + if !t.After(f.cur) { + return nil + } + if err := writeFloor(f.path, t.UTC()); err != nil { + return err + } + f.cur = t.UTC() + return nil +} + +// writeFloor replaces the floor file atomically, so a crash mid-write leaves +// the old floor rather than a torn one. +func writeFloor(path string, t time.Time) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".clock-floor-*") + if err != nil { + return fmt.Errorf("timesync: write clock floor: %w", err) + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.WriteString(t.Format(time.RFC3339Nano) + "\n"); err != nil { + _ = tmp.Close() + return fmt.Errorf("timesync: write clock floor: %w", err) + } + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("timesync: write clock floor: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("timesync: write clock floor: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("timesync: write clock floor: %w", err) + } + if err := os.Rename(tmp.Name(), path); err != nil { + return fmt.Errorf("timesync: write clock floor: %w", err) + } + return nil +} diff --git a/internal/timesync/floor_test.go b/internal/timesync/floor_test.go new file mode 100644 index 0000000..8607e0f --- /dev/null +++ b/internal/timesync/floor_test.go @@ -0,0 +1,104 @@ +package timesync + +/* +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 ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestFloorStartsAtBuildTime(t *testing.T) { + build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + f, err := OpenFloor(filepath.Join(t.TempDir(), "clock-floor"), build) + if err != nil { + t.Fatalf("OpenFloor: %v", err) + } + if got := f.Get(); !got.Equal(build) { + t.Fatalf("Get = %v, want the build time %v", got, build) + } +} + +func TestFloorAdvancePersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "clock-floor") + build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + f, err := OpenFloor(path, build) + if err != nil { + t.Fatalf("OpenFloor: %v", err) + } + later := time.Date(2026, 9, 29, 12, 0, 0, 5, time.UTC) + if err := f.Advance(later); err != nil { + t.Fatalf("Advance: %v", err) + } + // An earlier time never lowers the floor. + if err := f.Advance(later.Add(-time.Hour)); err != nil { + t.Fatalf("Advance: %v", err) + } + g, err := OpenFloor(path, build) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if got := g.Get(); !got.Equal(later) { + t.Fatalf("reopened floor = %v, want %v", got, later) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("floor file mode = %v, want 0600", info.Mode().Perm()) + } +} + +// A build time later than the persisted floor wins: a new image never runs +// behind the day it was built. +func TestFloorBuildTimeWinsWhenLater(t *testing.T) { + path := filepath.Join(t.TempDir(), "clock-floor") + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + f, _ := OpenFloor(path, time.Time{}) + if err := f.Advance(old); err != nil { + t.Fatal(err) + } + build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + g, err := OpenFloor(path, build) + if err != nil { + t.Fatal(err) + } + if got := g.Get(); !got.Equal(build) { + t.Fatalf("Get = %v, want %v", got, build) + } +} + +// A damaged floor file is reported, not silently treated as "no floor", and +// the floor still holds at the build time. +func TestFloorCorruptFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "clock-floor") + if err := os.WriteFile(path, []byte("not a time\n"), 0o600); err != nil { + t.Fatal(err) + } + build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + f, err := OpenFloor(path, build) + if err == nil { + t.Fatal("OpenFloor accepted a corrupt floor file") + } + if f == nil || !f.Get().Equal(build) { + t.Fatal("OpenFloor must still return a floor at the build time") + } +} diff --git a/internal/timesync/packet.go b/internal/timesync/packet.go new file mode 100644 index 0000000..e63b2ed --- /dev/null +++ b/internal/timesync/packet.go @@ -0,0 +1,305 @@ +package timesync + +/* +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/binary" + "errors" + "fmt" + "math/bits" + "slices" + "time" +) + +// Wire constants from RFC 5905 (NTPv4) and RFC 4330 (SNTPv4). +const ( + // PacketLen is the NTP header without extension fields or a MAC. + PacketLen = 48 + // Port is the NTP server port. + Port = 123 + + ModeClient = 3 + ModeServer = 4 + + version = 4 + // leapUnsynchronized is LI=3: the server's clock is not synchronised. + leapUnsynchronized = 3 + // maxStratum is the highest usable stratum; 16 means unsynchronised. + maxStratum = 15 + // MaxRootDistance is NTP's MAXDIST: a server whose root distance exceeds + // it is too far from a reference clock to trust. + MaxRootDistance = time.Second + // AgreementWindow is the widest spread of offsets from different servers + // in one round that still counts as agreement. + AgreementWindow = time.Second +) + +// eraSeconds is one NTP era: the 32-bit seconds field wraps every 2^32 s. +const eraSeconds = int64(1) << 32 + +// ntpToUnix is the number of seconds from the NTP prime epoch (1900-01-01) to +// the Unix epoch (1970-01-01). +const ntpToUnix = int64(2208988800) + +// Timestamp is the 64-bit NTP timestamp format: 32 bits of seconds since the +// start of the era and 32 bits of fraction (RFC 5905 section 6). +type Timestamp uint64 + +// TimestampFromTime encodes t, dropping the era, as RFC 5905 does on the wire. +func TimestampFromTime(t time.Time) Timestamp { + secs := t.Unix() + ntpToUnix + frac := (uint64(t.Nanosecond()) << 32) / uint64(time.Second) + return Timestamp(uint64(uint32(secs))<<32 | frac) +} + +// Time decodes ts into the era nearest pivot. The wire format carries no era, +// so a client that assumed era 0 would jump back 136 years on 2036-02-07; the +// nearest-era rule is correct whenever the pivot is within 68 years of the +// true time, which the clock floor guarantees. +func (ts Timestamp) Time(pivot time.Time) time.Time { + p := pivot.Unix() + ntpToUnix + secs := int64(uint64(ts) >> 32) + era := p >> 32 + best := era*eraSeconds + secs + for _, c := range []int64{best - eraSeconds, best + eraSeconds} { + if abs64(c-p) < abs64(best-p) { + best = c + } + } + hi, lo := bits.Mul64(uint64(uint32(ts)), uint64(time.Second)) + nsec := int64(hi<<32 | lo>>32) + return time.Unix(best-ntpToUnix, nsec).UTC() +} + +// Short is the 32-bit NTP short format: 16 bits of seconds and 16 bits of +// fraction, used for root delay and root dispersion. +type Short uint32 + +// Duration converts s to a time.Duration. +func (s Short) Duration() time.Duration { + return time.Duration((uint64(s) * uint64(time.Second)) >> 16) +} + +// Header is the fixed 48-octet NTP packet header (RFC 5905 figure 8). +type Header struct { + Leap uint8 + Version uint8 + Mode uint8 + Stratum uint8 + Poll int8 + Precision int8 + RootDelay Short + RootDispersion Short + ReferenceID [4]byte + Reference Timestamp + Origin Timestamp + Receive Timestamp + Transmit Timestamp +} + +// NewRequest builds a client request. Every field but Transmit Timestamp is +// zero (RFC 4330 section 5). The transmit field carries nonce, a random value +// rather than the send time: the server echoes it as Origin, which lets the +// client drop replies that do not answer this request, and keeps the local +// clock off the wire. The real send time is kept by the caller. +func NewRequest(nonce Timestamp) Header { + return Header{Version: version, Mode: ModeClient, Transmit: nonce} +} + +// Marshal encodes h as a 48-octet packet. +func (h Header) Marshal() []byte { + b := make([]byte, PacketLen) + b[0] = h.Leap<<6 | (h.Version&0x7)<<3 | h.Mode&0x7 + b[1] = h.Stratum + b[2] = byte(h.Poll) + b[3] = byte(h.Precision) + binary.BigEndian.PutUint32(b[4:], uint32(h.RootDelay)) + binary.BigEndian.PutUint32(b[8:], uint32(h.RootDispersion)) + copy(b[12:16], h.ReferenceID[:]) + binary.BigEndian.PutUint64(b[16:], uint64(h.Reference)) + binary.BigEndian.PutUint64(b[24:], uint64(h.Origin)) + binary.BigEndian.PutUint64(b[32:], uint64(h.Receive)) + binary.BigEndian.PutUint64(b[40:], uint64(h.Transmit)) + return b +} + +// ParseHeader decodes the header from b. Anything after the first 48 octets +// (extension fields, a MAC) is ignored, as RFC 4330 allows a client to do. +func ParseHeader(b []byte) (Header, error) { + if len(b) < PacketLen { + return Header{}, fmt.Errorf("timesync: short packet: %d octets, want at least %d", len(b), PacketLen) + } + var h Header + h.Leap = b[0] >> 6 + h.Version = (b[0] >> 3) & 0x7 + h.Mode = b[0] & 0x7 + h.Stratum = b[1] + h.Poll = int8(b[2]) + h.Precision = int8(b[3]) + h.RootDelay = Short(binary.BigEndian.Uint32(b[4:])) + h.RootDispersion = Short(binary.BigEndian.Uint32(b[8:])) + copy(h.ReferenceID[:], b[12:16]) + h.Reference = Timestamp(binary.BigEndian.Uint64(b[16:])) + h.Origin = Timestamp(binary.BigEndian.Uint64(b[24:])) + h.Receive = Timestamp(binary.BigEndian.Uint64(b[32:])) + h.Transmit = Timestamp(binary.BigEndian.Uint64(b[40:])) + return h, nil +} + +// Kiss-o'-Death codes the client must act on (RFC 5905 section 7.4). +const ( + KissDeny = "DENY" + KissRstr = "RSTR" + KissRate = "RATE" +) + +// KissError is a Kiss-o'-Death reply: stratum 0 with a four-character code +// in the Reference ID. +type KissError struct { + Code string +} + +func (e *KissError) Error() string { + return fmt.Sprintf("timesync: kiss-o'-death %q", e.Code) +} + +// ValidateReply applies the RFC 4330 section 5 and RFC 5905 checks to a reply +// to the request that carried nonce. A reply that fails any of them must not +// be used to set the clock. +func ValidateReply(h Header, nonce Timestamp) error { + if h.Mode != ModeServer { + return fmt.Errorf("timesync: reply mode %d, want %d (server)", h.Mode, ModeServer) + } + // RFC 4330 section 5: a server may answer an NTPv4 request as NTPv3. + if h.Version != 3 && h.Version != 4 { + return fmt.Errorf("timesync: reply version %d, want 3 or 4", h.Version) + } + if h.Origin != nonce { + return errors.New("timesync: reply origin timestamp does not match the request (bogus or replayed reply)") + } + // A kiss is checked before the leap indicator because a kiss carries LI=3 + // too, and the code decides what the client must do next. + if h.Stratum == 0 { + return &KissError{Code: kissCode(h.ReferenceID)} + } + if h.Transmit == 0 { + return errors.New("timesync: reply transmit timestamp is zero") + } + if h.Leap == leapUnsynchronized { + return errors.New("timesync: server clock is not synchronised (leap indicator 3)") + } + if h.Stratum > maxStratum { + return fmt.Errorf("timesync: server stratum %d is unsynchronised", h.Stratum) + } + if d := RootDistance(h); d > MaxRootDistance { + return fmt.Errorf("timesync: server root distance %v exceeds %v", d, MaxRootDistance) + } + return nil +} + +// RootDistance is half the root delay plus the root dispersion: how far the +// server may be from its reference clock (RFC 5905 section 11.2.1, without +// the terms a stateless client cannot know). +func RootDistance(h Header) time.Duration { + return h.RootDelay.Duration()/2 + h.RootDispersion.Duration() +} + +func kissCode(id [4]byte) string { + n := len(id) + for n > 0 && id[n-1] == 0 { + n-- + } + return string(id[:n]) +} + +// Compute returns the clock offset and round-trip delay for one exchange +// (RFC 5905 section 8): t1 is the local send time, t4 the local receive time, +// and the reply's Receive and Transmit are the server's t2 and t3, decoded +// into the era nearest pivot. The offset is the server's time minus ours. +func Compute(t1, t4 time.Time, h Header, pivot time.Time) (offset, delay time.Duration, err error) { + t2 := h.Receive.Time(pivot) + t3 := h.Transmit.Time(pivot) + offset = (t2.Sub(t1) + t3.Sub(t4)) / 2 + delay = t4.Sub(t1) - t3.Sub(t2) + if delay < 0 { + return 0, 0, fmt.Errorf("timesync: negative round-trip delay %v", delay) + } + return offset, delay, nil +} + +// Sample is one usable reply. +type Sample struct { + // Server is the server as queried: the hostname or the IPv4 literal. + Server string + Addr string + Offset time.Duration + Delay time.Duration + Stratum uint8 + RootDistance time.Duration +} + +// ErrDisagree reports that the servers answering one round disagree by more +// than AgreementWindow, so none of them is trusted. +var ErrDisagree = errors.New("timesync: sources disagree") + +// Combine picks the offset to apply from one round of samples: the median. +// With an even count it is the mean of the middle two, reported against the +// lower of them. If two or more servers answered and their offsets spread +// wider than AgreementWindow the round is refused: this is the cheap defence +// against one bad or spoofed source, in place of NTP's selection algorithms. +func Combine(samples []Sample) (Sample, error) { + if len(samples) == 0 { + return Sample{}, errors.New("timesync: no usable replies") + } + s := slices.Clone(samples) + slices.SortStableFunc(s, func(a, b Sample) int { + switch { + case a.Offset < b.Offset: + return -1 + case a.Offset > b.Offset: + return 1 + } + return 0 + }) + if spread := s[len(s)-1].Offset - s[0].Offset; spread > AgreementWindow { + return Sample{}, fmt.Errorf("%w: offsets spread %v across %d servers (more than %v)", ErrDisagree, spread, len(s), AgreementWindow) + } + n := len(s) + if n%2 == 1 { + return s[n/2], nil + } + lo, hi := s[n/2-1], s[n/2] + out := lo + out.Offset = lo.Offset + (hi.Offset-lo.Offset)/2 + return out, nil +} + +func abs64(v int64) int64 { + if v < 0 { + return -v + } + return v +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} diff --git a/internal/timesync/packet_test.go b/internal/timesync/packet_test.go new file mode 100644 index 0000000..9c05031 --- /dev/null +++ b/internal/timesync/packet_test.go @@ -0,0 +1,277 @@ +package timesync + +/* +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 ( + "errors" + "testing" + "time" +) + +func TestRequestHeader(t *testing.T) { + nonce := Timestamp(0x0123456789abcdef) + b := NewRequest(nonce).Marshal() + if len(b) != PacketLen { + t.Fatalf("request is %d bytes, want %d", len(b), PacketLen) + } + // LI=0, VN=4, Mode=3 (client). + if b[0] != 0x23 { + t.Fatalf("first octet = %#02x, want 0x23", b[0]) + } + for i := 1; i < 40; i++ { + if b[i] != 0 { + t.Fatalf("octet %d = %#02x, want every field but Transmit Timestamp zero", i, b[i]) + } + } + h, err := ParseHeader(b) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if h.Transmit != nonce { + t.Fatalf("Transmit = %#x, want the nonce %#x", uint64(h.Transmit), uint64(nonce)) + } +} + +func TestHeaderRoundTrip(t *testing.T) { + in := Header{ + Leap: 1, Version: 4, Mode: ModeServer, Stratum: 2, Poll: 6, Precision: -20, + RootDelay: 0x00010000, RootDispersion: 0x00008000, ReferenceID: [4]byte{'G', 'P', 'S', 0}, + Reference: 1, Origin: 2, Receive: 3, Transmit: 4, + } + out, err := ParseHeader(in.Marshal()) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if out != in { + t.Fatalf("round trip = %+v, want %+v", out, in) + } +} + +func TestParseHeaderRejectsShortPacket(t *testing.T) { + if _, err := ParseHeader(make([]byte, PacketLen-1)); err == nil { + t.Fatal("ParseHeader accepted a 47-octet packet") + } +} + +// Extension fields and a MAC may follow the header; an SNTP client ignores +// them (RFC 4330 section 4). +func TestParseHeaderIgnoresTrailingData(t *testing.T) { + b := append(NewRequest(7).Marshal(), make([]byte, 20)...) + if _, err := ParseHeader(b); err != nil { + t.Fatalf("ParseHeader: %v", err) + } +} + +func TestTimestampConversion(t *testing.T) { + unixEpoch := time.Unix(0, 0).UTC() + if got := TimestampFromTime(unixEpoch); got != Timestamp(uint64(2208988800)<<32) { + t.Fatalf("Unix epoch = %#x, want 2208988800<<32", uint64(got)) + } + half := unixEpoch.Add(500 * time.Millisecond) + if got := TimestampFromTime(half); uint32(got) != 0x80000000 { + t.Fatalf("fraction of 0.5 s = %#x, want 0x80000000", uint32(got)) + } + now := time.Date(2026, 9, 29, 12, 0, 0, 123456789, time.UTC) + back := TimestampFromTime(now).Time(now) + if d := back.Sub(now); d < -time.Nanosecond || d > time.Nanosecond { + t.Fatalf("round trip drifted by %v", d) + } +} + +// RFC 5905 section 6: the 32-bit seconds field wraps on 2036-02-07T06:28:16Z +// (the start of era 1). A timestamp maps into the era nearest the pivot, never +// assuming era 0. +func TestTimestampEraRollover(t *testing.T) { + era1 := time.Date(2036, 2, 7, 6, 28, 16, 0, time.UTC) + cases := []struct { + name string + ts Timestamp + pivot time.Time + want time.Time + }{ + {"era 1 start, pivot just after", 0, era1.Add(4 * time.Second), era1}, + {"era 1 start, pivot just before", 0, era1.Add(-4 * time.Second), era1}, + {"end of era 0, pivot after the wrap", Timestamp(uint64(0xfffffff0) << 32), era1.Add(time.Hour), era1.Add(-16 * time.Second)}, + {"well into era 1", Timestamp(uint64(86400) << 32), era1.Add(-time.Hour), era1.Add(24 * time.Hour)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.ts.Time(tc.pivot); !got.Equal(tc.want) { + t.Fatalf("Time = %v, want %v", got, tc.want) + } + }) + } + after := era1.Add(90 * time.Minute) + if got := TimestampFromTime(after); uint64(got)>>32 != 5400 { + t.Fatalf("seconds field after the wrap = %d, want 5400", uint64(got)>>32) + } +} + +func TestShortDuration(t *testing.T) { + if got := Short(0x00018000).Duration(); got != 1500*time.Millisecond { + t.Fatalf("Duration = %v, want 1.5s", got) + } +} + +func goodReply(nonce Timestamp) Header { + return Header{ + Version: 4, Mode: ModeServer, Stratum: 2, + RootDelay: 0x00000800, RootDispersion: 0x00000800, + Origin: nonce, Receive: 100, Transmit: 101, + } +} + +func TestValidateReply(t *testing.T) { + const nonce = Timestamp(0xfeedfacecafebeef) + cases := []struct { + name string + mutate func(*Header) + wantKiss string + ok bool + }{ + {"good", func(*Header) {}, "", true}, + {"version 3 reply is accepted", func(h *Header) { h.Version = 3 }, "", true}, + {"version 2 is refused", func(h *Header) { h.Version = 2 }, "", false}, + {"version 5 is refused", func(h *Header) { h.Version = 5 }, "", false}, + {"client mode is not a reply", func(h *Header) { h.Mode = ModeClient }, "", false}, + {"broadcast is not a reply", func(h *Header) { h.Mode = 5 }, "", false}, + {"origin does not echo the nonce", func(h *Header) { h.Origin = nonce + 1 }, "", false}, + {"zero transmit timestamp", func(h *Header) { h.Transmit = 0 }, "", false}, + {"leap indicator 3 means the server is unsynchronised", func(h *Header) { h.Leap = 3 }, "", false}, + {"leap indicator 1 is a warning, not a failure", func(h *Header) { h.Leap = 1 }, "", true}, + {"stratum 16 is unsynchronised", func(h *Header) { h.Stratum = 16 }, "", false}, + {"stratum 15 is the last valid one", func(h *Header) { h.Stratum = 15 }, "", true}, + {"root distance over 1 s", func(h *Header) { h.RootDelay = 0x00020000 }, "", false}, + {"root dispersion over 1 s", func(h *Header) { h.RootDispersion = 0x00010001 }, "", false}, + {"kiss DENY", func(h *Header) { h.Stratum = 0; h.Leap = 3; h.ReferenceID = [4]byte{'D', 'E', 'N', 'Y'} }, "DENY", false}, + {"kiss RSTR", func(h *Header) { h.Stratum = 0; h.ReferenceID = [4]byte{'R', 'S', 'T', 'R'} }, "RSTR", false}, + {"kiss RATE", func(h *Header) { h.Stratum = 0; h.ReferenceID = [4]byte{'R', 'A', 'T', 'E'} }, "RATE", false}, + {"kiss with another code", func(h *Header) { h.Stratum = 0; h.ReferenceID = [4]byte{'I', 'N', 'I', 'T'} }, "INIT", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := goodReply(nonce) + tc.mutate(&h) + err := ValidateReply(h, nonce) + if tc.ok { + if err != nil { + t.Fatalf("ValidateReply: %v", err) + } + return + } + if err == nil { + t.Fatal("ValidateReply accepted a reply it must discard") + } + var kiss *KissError + if tc.wantKiss != "" { + if !errors.As(err, &kiss) || kiss.Code != tc.wantKiss { + t.Fatalf("error = %v, want a kiss-o'-death %q", err, tc.wantKiss) + } + } else if errors.As(err, &kiss) { + t.Fatalf("error = %v, want a plain rejection, not a kiss-o'-death", err) + } + }) + } +} + +func TestComputeOffsetAndDelay(t *testing.T) { + t1 := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC) + t2 := t1.Add(1500 * time.Millisecond) + t3 := t1.Add(1600 * time.Millisecond) + t4 := t1.Add(200 * time.Millisecond) + h := Header{Receive: TimestampFromTime(t2), Transmit: TimestampFromTime(t3)} + offset, delay, err := Compute(t1, t4, h, t1) + if err != nil { + t.Fatalf("Compute: %v", err) + } + if !near(offset, 1450*time.Millisecond) { + t.Errorf("offset = %v, want 1.45s", offset) + } + if !near(delay, 100*time.Millisecond) { + t.Errorf("delay = %v, want 100ms", delay) + } +} + +func TestComputeRejectsNegativeDelay(t *testing.T) { + t1 := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC) + h := Header{Receive: TimestampFromTime(t1), Transmit: TimestampFromTime(t1.Add(time.Second))} + if _, _, err := Compute(t1, t1.Add(10*time.Millisecond), h, t1); err == nil { + t.Fatal("Compute accepted a negative round-trip delay") + } +} + +// Across the 2036 wrap the client and server timestamps sit in different eras; +// the offset must still be the true few seconds. +func TestComputeAcrossEraBoundary(t *testing.T) { + era1 := time.Date(2036, 2, 7, 6, 28, 16, 0, time.UTC) + t1 := era1.Add(-2 * time.Second) + t4 := t1.Add(10 * time.Millisecond) + srv := era1.Add(3 * time.Second) + h := Header{Receive: TimestampFromTime(srv), Transmit: TimestampFromTime(srv)} + offset, _, err := Compute(t1, t4, h, t1) + if err != nil { + t.Fatalf("Compute: %v", err) + } + if !near(offset, 4995*time.Millisecond) { + t.Fatalf("offset = %v, want about 5s", offset) + } +} + +func TestCombine(t *testing.T) { + s := func(name string, off time.Duration) Sample { return Sample{Server: name, Offset: off, Stratum: 2} } + cases := []struct { + name string + in []Sample + wantOffset time.Duration + wantServer string + disagree bool + }{ + {"one", []Sample{s("a", 3*time.Second)}, 3 * time.Second, "a", false}, + {"three take the median", []Sample{s("a", 2300*time.Millisecond), s("b", 2*time.Second), s("c", 2100*time.Millisecond)}, 2100 * time.Millisecond, "c", false}, + {"two take the mean", []Sample{s("a", 100*time.Millisecond), s("b", 300*time.Millisecond)}, 200 * time.Millisecond, "a", false}, + {"two more than 1 s apart", []Sample{s("a", 0), s("b", 5*time.Second)}, 0, "", true}, + {"one outlier among three", []Sample{s("a", 0), s("b", 10*time.Millisecond), s("c", 1500*time.Millisecond)}, 0, "", true}, + {"exactly 1 s apart agrees", []Sample{s("a", 0), s("b", time.Second)}, 500 * time.Millisecond, "a", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Combine(tc.in) + if tc.disagree { + if !errors.Is(err, ErrDisagree) { + t.Fatalf("Combine error = %v, want ErrDisagree", err) + } + return + } + if err != nil { + t.Fatalf("Combine: %v", err) + } + if got.Offset != tc.wantOffset || got.Server != tc.wantServer { + t.Fatalf("Combine = %s %v, want %s %v", got.Server, got.Offset, tc.wantServer, tc.wantOffset) + } + }) + } + if _, err := Combine(nil); err == nil { + t.Fatal("Combine accepted no samples") + } +} + +func near(got, want time.Duration) bool { + d := got - want + return d > -time.Millisecond && d < time.Millisecond +} From 990e4c61906dbf57d4fa2e7545e730c610b459fe Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:15:18 -0400 Subject: [PATCH 3/5] feat(node): gate certificate signing on the first clock sync Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- internal/node/node_test.go | 29 +++++++ internal/node/providers.go | 8 ++ internal/node/signer.go | 56 ++++++++++++ internal/node/signer_clockgate_test.go | 113 +++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 internal/node/signer_clockgate_test.go diff --git a/internal/node/node_test.go b/internal/node/node_test.go index 21ce2a1..c6d736f 100644 --- a/internal/node/node_test.go +++ b/internal/node/node_test.go @@ -492,3 +492,32 @@ func TestConfigStoreCurrentWithNoConfigIsErrNoConfig(t *testing.T) { t.Fatalf("Current with no persisted config = %v, want ErrNoConfig", err) } } + +// The time-sync state is read per GetStatus, so the first good sync after boot +// shows up without a reboot. Unwired, the field stays unset. +func TestStatusProviderReportsTimeSync(t *testing.T) { + s, ctx := newTestStore(t) + ts := &cryptosv1.TimeSyncStatus{State: cryptosv1.TimeSyncState_TIME_SYNC_STATE_PENDING} + sp, err := NewStatusProvider(StatusConfig{ + Store: s, + Role: cryptosv1.NodeRole_NODE_ROLE_ISSUING, + TimeSync: func() *cryptosv1.TimeSyncStatus { return ts }, + }) + if err != nil { + t.Fatalf("NewStatusProvider: %v", err) + } + if st, _ := sp.Status(ctx); st.GetTimeSync() != ts { + t.Fatalf("time_sync = %v, want %v", st.GetTimeSync(), ts) + } + ts = &cryptosv1.TimeSyncStatus{State: cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED} + if st, _ := sp.Status(ctx); st.GetTimeSync().GetState() != cryptosv1.TimeSyncState_TIME_SYNC_STATE_SYNCED { + t.Fatalf("a later sync was not reflected: %v", st.GetTimeSync()) + } + bare, err := NewStatusProvider(StatusConfig{Store: s, Role: cryptosv1.NodeRole_NODE_ROLE_ROOT}) + if err != nil { + t.Fatal(err) + } + if st, _ := bare.Status(ctx); st.GetTimeSync() != nil { + t.Fatalf("unwired time sync must leave the field unset: %v", st.GetTimeSync()) + } +} diff --git a/internal/node/providers.go b/internal/node/providers.go index ef5b086..f7d983d 100644 --- a/internal/node/providers.go +++ b/internal/node/providers.go @@ -79,6 +79,9 @@ type StatusConfig struct { // ProtocolRunning reports whether a protocol's listener started this // boot; nil reports every protocol as not running. ProtocolRunning func(cryptosv1.ServiceProtocol) bool + // TimeSync reports the SNTP clock synchronisation state; nil leaves + // NodeStatus.time_sync unset. + TimeSync func() *cryptosv1.TimeSyncStatus } // StatusProvider adapts a Store + live health probes to grpc.StatusProvider. @@ -122,6 +125,10 @@ func (p *StatusProvider) Status(ctx context.Context) (*cryptosv1.NodeStatus, err resolver = p.cfg.Resolver() } protocols, rebootPending := p.protocols() + var timeSync *cryptosv1.TimeSyncStatus + if p.cfg.TimeSync != nil { + timeSync = p.cfg.TimeSync() + } return &cryptosv1.NodeStatus{ Role: p.cfg.Role, IdentityState: phase.IdentityState(), @@ -137,6 +144,7 @@ func (p *StatusProvider) Status(ctx context.Context) (*cryptosv1.NodeStatus, err Resolver: resolver, Protocols: protocols, ConfigRebootPending: rebootPending, + TimeSync: timeSync, }, nil } diff --git a/internal/node/signer.go b/internal/node/signer.go index 9a5d9cc..9d0ba03 100644 --- a/internal/node/signer.go +++ b/internal/node/signer.go @@ -26,6 +26,7 @@ import ( "encoding/asn1" "encoding/pem" "errors" + "log" "net" "net/url" "slices" @@ -78,6 +79,15 @@ type CASigner struct { // certificate is returned: if recording fails, issuance fails and the // certificate is NOT returned, so every returned certificate is tracked. recordIssued func(ctx context.Context, der []byte, profileName string) error + + // clockSynced reports whether the node's clock gate is open: true with no + // time source, or once a configured source has synced this boot. Nil + // (no time sync wired) never gates, the same as a node with no source. + clockSynced func() bool + + // issued is told the notBefore of every certificate returned, so the + // clock floor can be raised past it. Nil is a no-op. + issued func(notBefore time.Time) } // NewCASigner constructs a CASigner from a key loader, an issuer-cert getter, @@ -97,6 +107,44 @@ func (s *CASigner) WithPreflight(ok func(ctx context.Context) bool) *CASigner { return s } +// WithClockGate wires the clock gate and returns the same CASigner for +// chaining. While synced reports false, every signing path refuses with +// FailedPrecondition before the CA key is loaded, unless the live config sets +// pki.allow_unsynced_clock. The dates a CA stamps are only as good as its +// clock, and a node whose time source has never answered may be far off. +func (s *CASigner) WithClockGate(synced func() bool) *CASigner { + s.clockSynced = synced + return s +} + +// WithIssuedHook wires a callback told the notBefore of every certificate the +// signer returns, and returns the same CASigner for chaining. +func (s *CASigner) WithIssuedHook(fn func(notBefore time.Time)) *CASigner { + s.issued = fn + return s +} + +// checkClock applies the clock gate for one signing request. +func (s *CASigner) checkClock(cfg *config.Config, what string) error { + if s.clockSynced == nil || s.clockSynced() { + return nil + } + if cfg.PKI.AllowUnsyncedClock { + log.Printf("node: WARNING: %s signed on an unsynced clock: the time source has not synced this boot and pki.allow_unsynced_clock is set", what) + return nil + } + log.Printf("node: %s refused: the clock has not synced with its configured time source this boot", what) + return status.Error(codes.FailedPrecondition, + "node: the clock has not synced with its configured time source yet; signing is refused (set pki.allow_unsynced_clock to override)") +} + +// noteIssued reports a returned certificate's notBefore to the issued hook. +func (s *CASigner) noteIssued(notBefore time.Time) { + if s.issued != nil { + s.issued(notBefore) + } +} + // WithRecorder wires the issued-certificate recorder and returns the same // CASigner for chaining. When set, IssueLeaf and SignSubordinate record every // minted certificate before returning it; if recording fails the issuance @@ -131,6 +179,9 @@ func (s *CASigner) SignSubordinate(ctx context.Context, csrDER []byte, profileNa if !prof.BasicConstraints.IsCA { return nil, "", nil, status.Errorf(codes.InvalidArgument, "node: profile %q is not a CA profile", profileName) } + if err := s.checkClock(cfg, "subordinate CA signing"); err != nil { + return nil, "", nil, err + } issuerCert, err := s.issuer(ctx) if err != nil { @@ -160,6 +211,7 @@ func (s *CASigner) SignSubordinate(ctx context.Context, csrDER []byte, profileNa if err := s.record(ctx, der, profileName); err != nil { return nil, "", nil, err } + s.noteIssued(p.NotBefore) chainDER = [][]byte{der} var sb strings.Builder @@ -322,6 +374,9 @@ func (s *CASigner) issueLeaf(ctx context.Context, csrDER []byte, profileName str return nil, nil, nil, nil, status.Error(codes.FailedPrecondition, "node: a ROOT node refuses to issue leaf certificates without the irreversible acknowledgement") } + if err := s.checkClock(cfg, "leaf issuance"); err != nil { + return nil, nil, nil, nil, err + } issuerCert, err = s.issuer(ctx) if err != nil { @@ -357,6 +412,7 @@ func (s *CASigner) issueLeaf(ctx context.Context, csrDER []byte, profileName str if err := s.record(ctx, der, profileName); err != nil { return nil, nil, nil, nil, err } + s.noteIssued(p.NotBefore) return der, pemBytes, issuerCert, vcap, nil } diff --git a/internal/node/signer_clockgate_test.go b/internal/node/signer_clockgate_test.go new file mode 100644 index 0000000..b4167f6 --- /dev/null +++ b/internal/node/signer_clockgate_test.go @@ -0,0 +1,113 @@ +package node + +/* +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 ( + "context" + "crypto" + "strings" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/CryptOS-PKI/cryptos/internal/config" +) + +// A configured time source that has not synced yet closes the gate: every +// signing path refuses with FailedPrecondition, before the CA key is loaded. +func TestClockGateRefusesSigningWhileUnsynced(t *testing.T) { + f := newSignerFixture(t) + cfg := caProfileConfig(config.RoleIssuing) + loaded := false + load, issuer, get := f.loaders(cfg, new(bool)) + wrapped := func(ctx context.Context) (crypto.Signer, func(), error) { + loaded = true + return load(ctx) + } + s := NewCASigner(wrapped, issuer, get).WithClockGate(func() bool { return false }) + + _, err := s.IssueLeaf(context.Background(), makeCSR(t, "node.example"), "leaf-server") + wantCode(t, err, codes.FailedPrecondition) + if !strings.Contains(status.Convert(err).Message(), "allow_unsynced_clock") { + t.Fatalf("refusal %q does not name the override", err) + } + _, _, err = s.IssueLeafForNames(context.Background(), makeCSR(t, "node.example"), "leaf-server", []string{"node.example"}) + wantCode(t, err, codes.FailedPrecondition) + _, _, err = s.IssueLeafWithRequestSANs(context.Background(), makeCSR(t, "node.example"), "leaf-server", nil) + wantCode(t, err, codes.FailedPrecondition) + + sub := NewCASigner(wrapped, issuer, func(context.Context) (*config.Config, error) { + return caProfileConfig(config.RoleIntermediate), nil + }).WithClockGate(func() bool { return false }) + _, _, _, err = sub.SignSubordinate(context.Background(), makeCSR(t, "Child CA"), "sub-ca") + wantCode(t, err, codes.FailedPrecondition) + + if loaded { + t.Fatal("the CA key was loaded although the clock gate refused") + } +} + +func TestClockGateAllowsSigningOnceSynced(t *testing.T) { + f := newSignerFixture(t) + load, issuer, get := f.loaders(caProfileConfig(config.RoleIssuing), new(bool)) + s := NewCASigner(load, issuer, get).WithClockGate(func() bool { return true }) + if _, err := s.IssueLeaf(context.Background(), makeCSR(t, "node.example"), "leaf-server"); err != nil { + t.Fatalf("IssueLeaf with a synced clock: %v", err) + } +} + +func TestClockGateOverrideAllowsSigningWhileUnsynced(t *testing.T) { + f := newSignerFixture(t) + cfg := caProfileConfig(config.RoleIntermediate) + cfg.PKI.AllowUnsyncedClock = true + load, issuer, get := f.loaders(cfg, new(bool)) + s := NewCASigner(load, issuer, get).WithClockGate(func() bool { return false }) + if _, _, _, err := s.SignSubordinate(context.Background(), makeCSR(t, "Child CA"), "sub-ca"); err != nil { + t.Fatalf("SignSubordinate with allow_unsynced_clock: %v", err) + } +} + +// With no time source the node wires no gate (or one that always passes), so +// signing is never gated. +func TestNoClockGateNeverGates(t *testing.T) { + f := newSignerFixture(t) + load, issuer, get := f.loaders(caProfileConfig(config.RoleIssuing), new(bool)) + s := NewCASigner(load, issuer, get) + if _, err := s.IssueLeaf(context.Background(), makeCSR(t, "node.example"), "leaf-server"); err != nil { + t.Fatalf("IssueLeaf with no clock gate: %v", err) + } +} + +// Every certificate the signer returns reports its notBefore, so the clock +// floor can be raised past it. +func TestIssuedHookSeesEveryNotBefore(t *testing.T) { + f := newSignerFixture(t) + load, issuer, get := f.loaders(caProfileConfig(config.RoleIssuing), new(bool)) + var seen []time.Time + s := NewCASigner(load, issuer, get).WithIssuedHook(func(nb time.Time) { seen = append(seen, nb) }) + before := time.Now().Add(-time.Second) + if _, err := s.IssueLeaf(context.Background(), makeCSR(t, "node.example"), "leaf-server"); err != nil { + t.Fatal(err) + } + if len(seen) != 1 || seen[0].Before(before) { + t.Fatalf("issued hook saw %v", seen) + } +} From e18367cb44714c3c0201a94a2537eb9a3d576e9b Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:15:18 -0400 Subject: [PATCH 4/5] feat(init): sync the clock at boot and report it in status Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- cmd/cryptosctl/cli_test.go | 46 ++++++++ cmd/cryptosctl/config.go | 5 +- cmd/cryptosctl/config_test.go | 44 +++++++ cmd/cryptosctl/status.go | 30 +++++ internal/init/run.go | 14 +++ internal/init/timesync.go | 151 ++++++++++++++++++++++++ internal/init/timesync_test.go | 209 +++++++++++++++++++++++++++++++++ 7 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 internal/init/timesync.go create mode 100644 internal/init/timesync_test.go diff --git a/cmd/cryptosctl/cli_test.go b/cmd/cryptosctl/cli_test.go index 1b54a3c..a451692 100644 --- a/cmd/cryptosctl/cli_test.go +++ b/cmd/cryptosctl/cli_test.go @@ -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" @@ -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) + } +} diff --git a/cmd/cryptosctl/config.go b/cmd/cryptosctl/config.go index 21819e8..2e593fd 100644 --- a/cmd/cryptosctl/config.go +++ b/cmd/cryptosctl/config.go @@ -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 } diff --git a/cmd/cryptosctl/config_test.go b/cmd/cryptosctl/config_test.go index e45e5b6..1813f0f 100644 --- a/cmd/cryptosctl/config_test.go +++ b/cmd/cryptosctl/config_test.go @@ -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()) + } + } + }) + } +} diff --git a/cmd/cryptosctl/status.go b/cmd/cryptosctl/status.go index c010eed..0411950 100644 --- a/cmd/cryptosctl/status.go +++ b/cmd/cryptosctl/status.go @@ -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() } @@ -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) diff --git a/internal/init/run.go b/internal/init/run.go index dd0435d..a796ba4 100644 --- a/internal/init/run.go +++ b/internal/init/run.go @@ -324,6 +324,18 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { log.Printf("init: resolver: %v", err) } done() + begin("clock") + + // 6b. Time sync. It needs the network and the resolver (a hostname server) + // and runs before etcd, the listeners and signing, so etcd leases, TLS + // validity and issued dates all start on corrected time. The boot sync is + // bounded and never fails the boot; while a configured source has not + // synced, the CA signer refuses to sign (see wireClockGate). The floor is + // recorded on the way down, before the state volume is closed. + timeSync := startTimeSync(ctx, cfg.Network, paths.Mount) + go timeSync.Run(ctx) + defer timeSync.Shutdown() + done() begin("embedded etcd") // 7. Master seed (audit + ceremony signing keys derive from it). @@ -417,6 +429,7 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { return false } }, + TimeSync: timeSync.Status, }) if err != nil { return err @@ -504,6 +517,7 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { crlBuilder := revocation.NewCRLBuilder(revStore, crlDur) ocspResp := revocation.NewOCSPResponder(revStore) caSigner.WithPreflight(preflight.Ensure).WithRecorder(IssuedRecorder(revStore)) + wireClockGate(caSigner, timeSync) revoker := &nodeRevoker{store: revStore, crlBuilder: crlBuilder, load: keyLoader, issuer: issuerFunc, chain: func(ctx context.Context) ([][]byte, error) { id, err := store.Identity(ctx) diff --git a/internal/init/timesync.go b/internal/init/timesync.go new file mode 100644 index 0000000..cd6dc96 --- /dev/null +++ b/internal/init/timesync.go @@ -0,0 +1,151 @@ +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 ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "log" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/buildinfo" + "github.com/CryptOS-PKI/cryptos/internal/config" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/timesync" +) + +const ( + // ipconfigNTPPath is where the kernel's ip= autoconfiguration reports the + // NTP servers (DHCP option 42) from its lease: one IPv4 address per line, + // at most three. They are not in /proc/net/pnp. + ipconfigNTPPath = "/proc/net/ipconfig/ntp_servers" + // clockFloorFile holds the clock floor on the state volume. + clockFloorFile = "clock-floor" + // timeSyncLogLevel is the node's level for time-sync logs. The node has + // no level knob; debug keeps each poll (a few lines a minute at most) + // in the kernel log, where a clock problem is diagnosed. + timeSyncLogLevel = slog.LevelDebug +) + +// startTimeSync picks the time source, runs the bounded boot sync and returns +// the engine. The periodic loop is started by the caller. It never fails the +// boot: a node that cannot reach time runs on its hardware clock, with signing +// gated until a later poll succeeds. +func startTimeSync(ctx context.Context, n config.Network, stateMount string) *timesync.Engine { + logger := slog.New(slog.NewTextHandler(log.Writer(), &slog.HandlerOptions{Level: timeSyncLogLevel})) + lease, err := readLease(ipconfigNTPPath) + if err != nil { + logger.Warn("time sync: could not read the DHCP NTP servers; ignoring the lease", "path", ipconfigNTPPath, "err", err) + } + servers, source := timeSourceFor(n, lease) + logger.Info("time sync source chosen", "source", source.String(), "servers", strings.Join(servers, ",")) + + build := parseBuildTime(buildinfo.Get().BuildDate) + floorPath := filepath.Join(stateMount, clockFloorFile) + floor, err := timesync.OpenFloor(floorPath, build) + if err != nil { + logger.Error("time sync: clock floor unreadable; holding the floor at the image build time", "path", floorPath, "err", err) + } + logger.Debug("clock floor loaded", "floor", floor.Get(), "build_time", build) + + e, err := timesync.New(timesync.Config{ + Servers: servers, + Source: source, + Clock: timesync.SystemClock(), + Floor: floor, + Logger: logger, + }) + if err != nil { + // Only a list longer than MaxServers fails New, and both sources are + // capped before this point. + logger.Error("time sync disabled", "err", err) + e, _ = timesync.New(timesync.Config{Clock: timesync.SystemClock(), Floor: floor, Logger: logger}) + return e + } + e.BootSync(ctx) + return e +} + +// wireClockGate connects the signer to the time-sync engine: signing waits for +// the first good sync, and every issued notBefore raises the clock floor. +func wireClockGate(s *node.CASigner, e *timesync.Engine) *node.CASigner { + return s.WithClockGate(e.SignAllowed).WithIssuedHook(e.NoteIssued) +} + +// timeSourceFor picks the node's time servers. network.ntp_servers wins when +// set. Otherwise the servers from the kernel DHCP lease are used. With +// neither, the source is TIME_SOURCE_NONE. +func timeSourceFor(n config.Network, lease []byte) ([]string, cryptosv1.TimeSource) { + if len(n.NTPServers) > 0 { + return n.NTPServers, cryptosv1.TimeSource_TIME_SOURCE_MACHINE_CONFIG + } + if servers := parseIPConfigNTP(lease); len(servers) > 0 { + return servers, cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE + } + return nil, cryptosv1.TimeSource_TIME_SOURCE_NONE +} + +// parseIPConfigNTP extracts the NTP servers from the kernel's option 42 file. +// The lease is input from the network, so each value is validated like a +// configured literal and anything else is dropped. +func parseIPConfigNTP(b []byte) []string { + var servers []string + seen := map[string]bool{} + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + a, err := config.ParseNameserver(strings.TrimSpace(sc.Text())) + if err != nil || seen[a.String()] || len(servers) == config.MaxNTPServers { + continue + } + seen[a.String()] = true + servers = append(servers, a.String()) + } + return servers +} + +// readLease reads the option 42 file; a missing file is no lease. +func readLease(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("init: read %s: %w", path, err) + } + return b, nil +} + +// parseBuildTime parses the stamped build date, or returns the zero time for +// a development build with none, which leaves the floor to the state volume. +func parseBuildTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t.UTC() +} diff --git a/internal/init/timesync_test.go b/internal/init/timesync_test.go new file mode 100644 index 0000000..568fa88 --- /dev/null +++ b/internal/init/timesync_test.go @@ -0,0 +1,209 @@ +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 ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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/config" + "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/timesync" +) + +func TestTimeSourceFor(t *testing.T) { + cases := []struct { + name string + configured []string + lease string + wantServers string + wantSource cryptosv1.TimeSource + }{ + {"machine config wins over the lease", []string{"time.example.org"}, "192.0.2.1\n", "time.example.org", cryptosv1.TimeSource_TIME_SOURCE_MACHINE_CONFIG}, + {"lease when config is empty", nil, "192.0.2.1\n192.0.2.2\n", "192.0.2.1,192.0.2.2", cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE}, + {"lease values are validated", nil, "192.0.2.1\n0.0.0.0\nnot-an-ip\n2001:db8::1\n224.0.1.1\n192.0.2.1\n", "192.0.2.1", cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE}, + {"lease is capped at three", nil, "192.0.2.1\n192.0.2.2\n192.0.2.3\n192.0.2.4\n", "192.0.2.1,192.0.2.2,192.0.2.3", cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE}, + {"lease with surrounding whitespace", nil, " 192.0.2.1 \n\n", "192.0.2.1", cryptosv1.TimeSource_TIME_SOURCE_DHCP_LEASE}, + {"neither", nil, "", "", cryptosv1.TimeSource_TIME_SOURCE_NONE}, + {"empty lease entries only", nil, "0.0.0.0\n", "", cryptosv1.TimeSource_TIME_SOURCE_NONE}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + servers, src := timeSourceFor(config.Network{NTPServers: tc.configured}, []byte(tc.lease)) + if got := strings.Join(servers, ","); got != tc.wantServers || src != tc.wantSource { + t.Fatalf("timeSourceFor = %q %v, want %q %v", got, src, tc.wantServers, tc.wantSource) + } + }) + } +} + +// The option 42 file the kernel writes under ip=dhcp may be absent (no lease, +// or a kernel without it); that is "no lease", not an error. +func TestReadLease(t *testing.T) { + dir := t.TempDir() + if got, err := readLease(filepath.Join(dir, "missing")); err != nil || got != nil { + t.Fatalf("missing lease file = %q, %v; want nothing and no error", got, err) + } + path := filepath.Join(dir, "ntp_servers") + if err := os.WriteFile(path, []byte("192.0.2.1\n"), 0o444); err != nil { + t.Fatal(err) + } + if got, err := readLease(path); err != nil || string(got) != "192.0.2.1\n" { + t.Fatalf("readLease = %q, %v", got, err) + } +} + +func TestParseBuildTime(t *testing.T) { + if got := parseBuildTime("2026-09-25T00:00:00Z"); !got.Equal(time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("parseBuildTime = %v", got) + } + if got := parseBuildTime("unknown"); !got.IsZero() { + t.Fatalf("parseBuildTime(unknown) = %v, want zero", got) + } +} + +// unsyncedEngine is a time-sync engine with a configured source that has not +// synced: the signing gate is closed. +func unsyncedEngine(t *testing.T) *timesync.Engine { + t.Helper() + floor, err := timesync.OpenFloor(filepath.Join(t.TempDir(), clockFloorFile), time.Time{}) + if err != nil { + t.Fatal(err) + } + e, err := timesync.New(timesync.Config{ + Servers: []string{"192.0.2.1"}, + Source: cryptosv1.TimeSource_TIME_SOURCE_MACHINE_CONFIG, + Clock: timesync.SystemClock(), + Floor: floor, + }) + if err != nil { + t.Fatal(err) + } + if e.SignAllowed() { + t.Fatal("an unsynced engine must close the gate") + } + return e +} + +// gateFixture is a soft CA behind a clock-gated signer, plus a revoker over +// the same CA key loader. +type gateFixture struct { + signer *node.CASigner + revoker *nodeRevoker + responder *ocspResponder + ctx context.Context +} + +func newGateFixture(t *testing.T, e *timesync.Engine) gateFixture { + t.Helper() + f, responder, ctx := newOCSPResponderFixture(t, 0) + load := func(context.Context) (crypto.Signer, func(), error) { return f.caKey, func() {}, nil } + issuer := func(context.Context) (*x509.Certificate, error) { return f.issuer, nil } + cfg := &config.Config{PKI: config.PKI{Profiles: []config.CertificateProfile{{ + Name: "leaf", KeyAlg: config.RootKeyECDSAP384, ValidityDays: 30, + KeyUsage: []string{"digital_signature"}, ExtKeyUsage: []string{"server_auth"}, + SANs: config.SubjectAltNames{DNS: []string{"leaf.example.org"}}, + }}}} + signer := wireClockGate(node.NewCASigner(load, issuer, func(context.Context) (*config.Config, error) { return cfg, nil }), e) + + 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() }) + revStore := revocation.NewStore(cli) + r := &nodeRevoker{store: revStore, crlBuilder: revocation.NewCRLBuilder(revStore, time.Hour), load: load, issuer: issuer} + return gateFixture{signer: signer, revoker: r, responder: responder, ctx: ctx} +} + +func testCSR(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "leaf.example.org"}}, key) + if err != nil { + t.Fatal(err) + } + return der +} + +// With the clock unsynced the gate refuses certificate signing, but CRL and +// OCSP generation, which share the same CA key loader, keep working: a +// stale-looking CRL is better than none, and revocation must keep flowing. +func TestClockGateNeverGatesCRLOrOCSP(t *testing.T) { + g := newGateFixture(t, unsyncedEngine(t)) + _, err := g.signer.IssueLeaf(g.ctx, testCSR(t), "leaf") + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("IssueLeaf on an unsynced clock = %v, want FailedPrecondition", err) + } + der, err := g.revoker.buildCRL(g.ctx) + if err != nil { + t.Fatalf("buildCRL on an unsynced clock: %v", err) + } + if _, err := x509.ParseRevocationList(der); err != nil { + t.Fatalf("CRL does not parse: %v", err) + } + if _, _, err := g.responder.ensure(g.ctx); err != nil { + t.Fatalf("OCSP responder on an unsynced clock: %v", err) + } +} + +// With no time source the gate never closes, and every certificate issued +// raises the clock floor. +func TestClockGateOpensWithNoTimeSource(t *testing.T) { + floor, err := timesync.OpenFloor(filepath.Join(t.TempDir(), clockFloorFile), time.Time{}) + if err != nil { + t.Fatal(err) + } + e, err := timesync.New(timesync.Config{Clock: timesync.SystemClock(), Floor: floor}) + if err != nil { + t.Fatal(err) + } + g := newGateFixture(t, e) + before := floor.Get() + if _, err := g.signer.IssueLeaf(g.ctx, testCSR(t), "leaf"); err != nil { + t.Fatalf("IssueLeaf with no time source: %v", err) + } + if !floor.Get().After(before) { + t.Fatal("issuing did not raise the clock floor") + } +} From 0b0515e1037b46c84de370ec614c9ab807e11a36 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:15:18 -0400 Subject: [PATCH 5/5] docs(time-sync): document the SNTP client and the signing gate Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- README.md | 3 ++ docs/time-sync.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 docs/time-sync.md diff --git a/README.md b/README.md index 1485ac5..e78c656 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` 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 diff --git a/docs/time-sync.md b/docs/time-sync.md new file mode 100644 index 0000000..697e257 --- /dev/null +++ b/docs/time-sync.md @@ -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`.