Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions cmd/ntpresponder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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() {
Expand Down
12 changes: 11 additions & 1 deletion ntp/ntske/cmd/ntskeserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ import (
"syscall"

"github.com/facebook/time/ntp/ntske"
"github.com/facebook/time/ntp/ntske/stats"
)

func main() {
addr := flag.String("addr", "127.0.0.1:4460", "address to listen on (host:port)")
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)
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions ntp/ntske/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions ntp/ntske/keystore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 10 additions & 2 deletions ntp/ntske/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ const (

// Stats receives per-connection counters emitted by a Server.
type Stats interface {
IncHandshakes() // a TLS handshake completed with ALPN "ntske/1"
IncErrors() // a connection ended in an NTS-KE Error response or failure
IncHandshakes() // a TLS handshake completed with ALPN "ntske/1"
IncErrors() // a connection ended in an NTS-KE Error response or failure
AddCookiesIssued(n int) // n NewCookie records were returned in a response
}

// Server holds the configuration for an NTS-KE server. The zero value is not
Expand Down Expand Up @@ -227,7 +228,9 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
if err := s.writeRecords(tlsConn, response); err != nil {
slog.Warn("ntske: writing response", "remote", conn.RemoteAddr(), "err", err)
s.incErrors()
return
}
s.addCookiesIssued(int(s.cookieCount()))
}

// validateRequest checks the client's records against RFC 8915 §4.1 and returns
Expand Down Expand Up @@ -470,3 +473,8 @@ func (s *Server) incErrors() {
s.Stats.IncErrors()
}
}
func (s *Server) addCookiesIssued(n int) {
if s.Stats != nil {
s.Stats.AddCookiesIssued(n)
}
}
12 changes: 8 additions & 4 deletions ntp/ntske/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ const (
// the -race detector stays quiet when the server calls it from its own
// goroutine while the test reads the counters.
type countingStats struct {
handshakes atomic.Int64
errors atomic.Int64
handshakes atomic.Int64
errors atomic.Int64
cookiesIssued atomic.Int64
}

func (s *countingStats) IncHandshakes() { s.handshakes.Add(1) }
func (s *countingStats) IncErrors() { s.errors.Add(1) }
func (s *countingStats) IncHandshakes() { s.handshakes.Add(1) }
func (s *countingStats) IncErrors() { s.errors.Add(1) }
func (s *countingStats) AddCookiesIssued(n int) { s.cookiesIssued.Add(int64(n)) }

// newTestTLSConfigs returns a matched server/client pair backed by a throwaway
// self-signed Ed25519 certificate, both pinned to TLS 1.3 and ALPN "ntske/1".
Expand Down Expand Up @@ -183,6 +185,7 @@ func TestServerIssuesCookies(t *testing.T) {

require.Equal(t, int64(1), stats.handshakes.Load())
require.Equal(t, int64(0), stats.errors.Load())
require.Equal(t, int64(4), stats.cookiesIssued.Load())
}

// TestServerDefaultCookieCount checks that a Server with Cookies unset issues
Expand Down Expand Up @@ -324,6 +327,7 @@ func TestServerRejectsWrongALPN(t *testing.T) {

require.Equal(t, int64(0), stats.handshakes.Load(), "wrong ALPN must not credit a handshake")
require.Equal(t, int64(1), stats.errors.Load())
require.Equal(t, int64(0), stats.cookiesIssued.Load())
}

// TestServerErrorResponses exercises the two protocol rejections the client can
Expand Down
89 changes: 89 additions & 0 deletions ntp/ntske/stats/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
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
cookiesIssued 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(),
"nts.cookies_issued": j.cookiesIssued.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)
}

// AddCookiesIssued atomically adds n to the NTS cookies-issued counter.
func (j *JSONStats) AddCookiesIssued(n int) {
j.cookiesIssued.Add(int64(n))
}
Loading
Loading