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)
}
82 changes: 82 additions & 0 deletions ntp/ntske/stats/json.go
Original file line number Diff line number Diff line change
@@ -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)
}
51 changes: 51 additions & 0 deletions ntp/ntske/stats/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading