diff --git a/cmd/ntpresponder/main.go b/cmd/ntpresponder/main.go index e98e6a1d..e9d3cf80 100644 --- a/cmd/ntpresponder/main.go +++ b/cmd/ntpresponder/main.go @@ -27,6 +27,7 @@ import ( "os/signal" "runtime" + "github.com/facebook/time/ntp/ntske" "github.com/facebook/time/ntp/responder/announce" "github.com/facebook/time/ntp/responder/checker" "github.com/facebook/time/ntp/responder/server" @@ -41,8 +42,10 @@ func main() { s := server.Server{} var ( - debugger bool - logLevel string + debugger bool + logLevel string + enableNTS bool // master switch for the NTS path + ntsKeystoreKeys int // size of the cookie master-key ring ) flag.StringVar(&logLevel, "loglevel", "info", "Set a log level. Can be: debug, info, warning, error") @@ -58,6 +61,8 @@ func main() { flag.DurationVar(&s.Config.ExtraOffset, "extraoffset", 0, "Extra offset to return to clients") flag.BoolVar(&s.Config.ManageLoopback, "manage-loopback", true, "Add/remove IPs. If false, these must be managed elsewhere") flag.TextVar(&s.Config.TimestampType, "timestamptype", timestamp.SWRX, fmt.Sprintf("Timestamp type. Can be: %s, %s", timestamp.HWRX, timestamp.SWRX)) + flag.BoolVar(&enableNTS, "enable-nts", false, "Enable NTS (Network Time Security) authenticated NTP") + flag.IntVar(&ntsKeystoreKeys, "nts-keystore-keys", 2, "Number of NTS cookie master keys kept in the rotating ring") flag.Parse() s.Config.IPs.SetDefault() @@ -79,6 +84,21 @@ func main() { log.Fatalf("Config is invalid: %v", err) } + if enableNTS { + if ntsKeystoreKeys < 1 { + log.Fatalf("nts-keystore-keys must be >= 1, got %d", ntsKeystoreKeys) + } + ks, err := ntske.NewInMemoryKeystore(ntske.InMemoryKeystoreOptions{ + MaxKeys: uint32(ntsKeystoreKeys), // #nosec G115 -- ntsKeystoreKeys guarded >= 1 + InitialKey: ntske.SharedTestMasterKey, + }) + if err != nil { + log.Fatalf("Failed to set up NTS keystore: %v", err) + } + s.Config.Keystore = ks + log.Info("NTS enabled: NTP requests carrying extension fields will be authenticated") + } + if debugger { log.Warningf("Staring profiler on %s", pprofHTTP) go func() { diff --git a/ntp/ntske/cmd/ntskeserver/main.go b/ntp/ntske/cmd/ntskeserver/main.go index 68bae181..60e47e6a 100644 --- a/ntp/ntske/cmd/ntskeserver/main.go +++ b/ntp/ntske/cmd/ntskeserver/main.go @@ -30,6 +30,7 @@ import ( "syscall" "github.com/facebook/time/ntp/ntske" + "github.com/facebook/time/ntp/ntske/stats" ) func main() { @@ -37,6 +38,7 @@ func main() { certFile := flag.String("cert", "/tmp/ntske_cert.pem", "TLS certificate PEM") keyFile := flag.String("key", "/tmp/ntske_key.pem", "TLS private key PEM") cookies := flag.Uint("cookies", 8, "number of cookies to issue per handshake") + monitoringPort := flag.Int("monitoringport", 0, "Port to run the JSON stats server on; 0 disables it") flag.Parse() if *cookies > math.MaxUint16 { slog.Error("invalid --cookies value exceeds uint16 range", "value", *cookies, "max", math.MaxUint16) @@ -49,16 +51,24 @@ func main() { os.Exit(1) } - keystore, err := ntske.NewInMemoryKeystore(ntske.InMemoryKeystoreOptions{}) + keystore, err := ntske.NewInMemoryKeystore(ntske.InMemoryKeystoreOptions{ + InitialKey: ntske.SharedTestMasterKey, + }) if err != nil { slog.Error("keystore", "err", err) os.Exit(1) } + st := &stats.JSONStats{} + if *monitoringPort != 0 { + go st.Start(*monitoringPort) + } + srv := &ntske.Server{ TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, Keystore: keystore, Cookies: uint16(*cookies), //nolint:gosec // bounded: math.MaxUint16 (65535) guard above exits + Stats: st, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/ntp/ntske/keystore.go b/ntp/ntske/keystore.go index 275ce18c..6bf3b590 100644 --- a/ntp/ntske/keystore.go +++ b/ntp/ntske/keystore.go @@ -76,6 +76,24 @@ const ( // deterministic AEAD whose 16-octet synthetic IV doubles as the auth tag. masterAEADID = protocol.AEADAESSIVCMAC512 ) + +// SharedTestMasterKey is a fixed 64-octet cookie master key used ONLY to bridge +// the gap until a Keychain-backed keystore lets every responder in the fleet +// share sealing keys. Seeding both the NTP responder and the standalone NTS-KE +// server with it (via InMemoryKeystoreOptions.InitialKey) lets cookies sealed +// by one open on the other. +// +// NOT for production: anyone holding these bytes can forge cookies. Delete this +// once the fleet-wide (Keychain) keystore lands. +var SharedTestMasterKey = []byte{ + 0xad, 0x17, 0x97, 0x8b, 0x12, 0x2b, 0x0c, 0xc1, 0x66, 0x81, 0x04, 0x55, + 0xb4, 0xbb, 0x9a, 0xca, 0x0e, 0x85, 0x9b, 0xf0, 0x2d, 0x19, 0xe1, 0xdd, + 0xb8, 0x1a, 0x85, 0xdb, 0x41, 0xd7, 0x48, 0x5f, 0xe4, 0x4a, 0x27, 0x06, + 0xaa, 0x2d, 0x1b, 0x01, 0x7f, 0xab, 0x86, 0xaa, 0xf9, 0xe9, 0x01, 0xee, + 0x8b, 0x3c, 0x2d, 0x58, 0x90, 0xdd, 0xa4, 0xc3, 0x46, 0x63, 0xb8, 0xa4, + 0x2b, 0x55, 0xe5, 0x0c, +} + const ( cookieKeyIDLen = 4 // big-endian master-key identifier cookieNonceLen = 16 // random per-cookie nonce, mixed into the SIV as AD @@ -131,6 +149,10 @@ var _ Keystore = (*InMemoryKeystore)(nil) type InMemoryKeystoreOptions struct { MaxKeys uint32 Rand io.Reader + // InitialKey, if set, seeds the ring with this exact master key instead of + // generating a random one, letting separate processes share a sealing key. + // Must be masterKeyLen octets. + InitialKey []byte } // NewInMemoryKeystore returns a keystore seeded with one freshly generated @@ -148,12 +170,34 @@ func NewInMemoryKeystore(opts InMemoryKeystoreOptions) (*InMemoryKeystore, error if opts.Rand != nil { ks.rand = opts.Rand } + if len(opts.InitialKey) > 0 { + if err := ks.seedKey(opts.InitialKey); err != nil { + return nil, err + } + return ks, nil + } if err := ks.Rotate(); err != nil { return nil, err } return ks, nil } +// seedKey installs key as the initial sealing key (Key ID 1). Unlike Rotate it +// takes a caller-supplied key rather than generating a random one. +func (ks *InMemoryKeystore) seedKey(key []byte) error { + if len(key) != masterKeyLen { + return fmt.Errorf("ntske: initial key must be %d octets, got %d", masterKeyLen, len(key)) + } + ks.mu.Lock() + defer ks.mu.Unlock() + ks.nextID++ + id := ks.nextID + ks.ring[id] = bytes.Clone(key) + ks.order = append(ks.order, id) + ks.current = id + return nil +} + // Rotate generates a new master key, makes it the sealing key, and ages out the // oldest key if the ring is over capacity. Cookies sealed with a still-present // key remain openable. diff --git a/ntp/ntske/keystore_test.go b/ntp/ntske/keystore_test.go index d689e41e..00fab022 100644 --- a/ntp/ntske/keystore_test.go +++ b/ntp/ntske/keystore_test.go @@ -243,3 +243,30 @@ func TestConcurrentSealOpen(t *testing.T) { require.NoError(t, err) } } + +// TestInitialKeyEnablesCrossKeystoreOpen verifies the shared-key bridge: two +// independent keystores seeded with the same InitialKey can open each other's +// cookies, which is what lets the standalone NTS-KE server and the NTP +// responder interoperate. +func TestInitialKeyEnablesCrossKeystoreOpen(t *testing.T) { + sealer, err := NewInMemoryKeystore(InMemoryKeystoreOptions{InitialKey: SharedTestMasterKey}) + require.NoError(t, err) + opener, err := NewInMemoryKeystore(InMemoryKeystoreOptions{InitialKey: SharedTestMasterKey}) + require.NoError(t, err) + + c2s, s2c := sessionKeys(64) + cookie, err := sealer.SealCookie(protocol.AEADAESSIVCMAC512, c2s, s2c) + require.NoError(t, err) + + _, gotC2S, gotS2C, err := opener.OpenCookie(cookie) + require.NoError(t, err) + require.Equal(t, c2s, gotC2S) + require.Equal(t, s2c, gotS2C) +} + +// TestInitialKeyWrongLength checks that a master key of the wrong size is +// rejected rather than silently producing a broken keystore. +func TestInitialKeyWrongLength(t *testing.T) { + _, err := NewInMemoryKeystore(InMemoryKeystoreOptions{InitialKey: make([]byte, masterKeyLen-1)}) + require.Error(t, err) +} diff --git a/ntp/ntske/stats/json.go b/ntp/ntske/stats/json.go new file mode 100644 index 00000000..e9821d00 --- /dev/null +++ b/ntp/ntske/stats/json.go @@ -0,0 +1,82 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +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. +*/ + +/* +Package stats implements statistics collection and reporting for the NTS-KE +server. It reports the per-connection counters emitted by ntske.Server +(completed handshakes and errors) as JSON over an HTTP interface. +*/ +package stats + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync/atomic" +) + +// JSONStats reports NTS-KE server metrics as JSON over an HTTP interface and +// satisfies the ntske.Stats interface. It is a passive implementation: only +// Start needs to be called to expose the counters. +type JSONStats struct { + // keep these aligned to 64-bit for sync/atomic + handshakes atomic.Int64 + errors atomic.Int64 +} + +// toMap converts the counters to a map for JSON export. +func (j *JSONStats) toMap() map[string]int64 { + return map[string]int64{ + "nts.ke.handshakes": j.handshakes.Load(), + "nts.ke.errors": j.errors.Load(), + } +} + +// handleRequest is a handler used for all http monitoring requests +func (j *JSONStats) handleRequest(w http.ResponseWriter, _ *http.Request) { + js, err := json.Marshal(j.toMap()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err = w.Write(js); err != nil { + slog.Error("ntske stats: failed to reply", "err", err) + } +} + +// Start launches the HTTP JSON metrics server on the given port. It blocks and +// is intended to be run in its own goroutine. +func (j *JSONStats) Start(port int) { + mux := http.NewServeMux() + mux.HandleFunc("/", j.handleRequest) + addr := fmt.Sprintf(":%d", port) + slog.Debug("starting ntske stats http server", "addr", addr) + if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec // local interop testing server, no timeouts needed + slog.Error("ntske stats: failed to start listener", "err", err) + } +} + +// IncHandshakes atomically adds 1 to the NTS-KE handshake counter. +func (j *JSONStats) IncHandshakes() { + j.handshakes.Add(1) +} + +// IncErrors atomically adds 1 to the NTS-KE error counter. +func (j *JSONStats) IncErrors() { + j.errors.Add(1) +} diff --git a/ntp/ntske/stats/json_test.go b/ntp/ntske/stats/json_test.go new file mode 100644 index 00000000..bff12ea5 --- /dev/null +++ b/ntp/ntske/stats/json_test.go @@ -0,0 +1,51 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +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. +*/ + +package stats + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJSONStatsHandshakes(t *testing.T) { + stats := JSONStats{} + + stats.IncHandshakes() + require.Equal(t, int64(1), stats.handshakes.Load()) +} + +func TestJSONStatsErrors(t *testing.T) { + stats := JSONStats{} + + stats.IncErrors() + require.Equal(t, int64(1), stats.errors.Load()) +} + +func TestJSONStatsToMap(t *testing.T) { + j := JSONStats{} + j.handshakes.Store(3) + j.errors.Store(5) + result := j.toMap() + + expectedMap := map[string]int64{ + "nts.ke.handshakes": 3, + "nts.ke.errors": 5, + } + + require.Equal(t, expectedMap, result) +}