diff --git a/ntp/ntske/master_key.go b/ntp/ntske/master_key.go new file mode 100644 index 00000000..2f8ab94d --- /dev/null +++ b/ntp/ntske/master_key.go @@ -0,0 +1,73 @@ +/* +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 ntske + +import ( + "crypto/sha256" + "fmt" + "log/slog" + "os" +) + +// LoadMasterKeyFromFile reads and length-validates the cookie master key. Bytes +// are read verbatim (no trimming) so KE and NTP agree on the key; a read error or +// under-length key is returned (fail closed) rather than starting with a bad key. +func LoadMasterKeyFromFile(path string) ([]byte, error) { + master, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ntske: read master key %q: %w", path, err) + } + if len(master) < masterKeyMinLength { + return nil, fmt.Errorf("%w: file %q has %d octets, need >= %d", + ErrMasterKeyTooShort, path, len(master), masterKeyMinLength) + } + return master, nil +} + +// MasterKeyFingerprint returns a non-secret len + SHA-256[:4] tag, safe to log, +// so an operator can confirm KE and NTP hold the same master. +func MasterKeyFingerprint(master []byte) string { + sum := sha256.Sum256(master) + return fmt.Sprintf("len=%d sha256[:4]=%x", len(master), sum[:4]) +} + +// KeystoreConfig selects the cookie keystore; both server binaries build through +// NewKeystore so their choice cannot diverge. +type KeystoreConfig struct { + MasterKeyPath string // set => fleet-wide DerivedKeystore; empty => dev/test InMemoryKeystore + MaxKeys uint32 // InMemoryKeystore ring size (dev/test path only) +} + +// NewKeystore returns a DerivedKeystore when cfg.MasterKeyPath is set (fail-closed +// on a bad key), else a dev/test InMemoryKeystore seeded with SharedTestMasterKey. +// It logs the non-secret master fingerprint so KE and NTP can be compared. +func NewKeystore(cfg KeystoreConfig) (Keystore, error) { + if cfg.MasterKeyPath == "" { + slog.Warn("ntske: no master key path set; using in-memory dev/test keystore (cookies are not fleet-portable)") + return NewInMemoryKeystore(InMemoryKeystoreOptions{ + MaxKeys: cfg.MaxKeys, + InitialKey: SharedTestMasterKey, + }) + } + master, err := LoadMasterKeyFromFile(cfg.MasterKeyPath) + if err != nil { + return nil, err + } + slog.Info("ntske: loaded cookie master key", + "path", cfg.MasterKeyPath, "fingerprint", MasterKeyFingerprint(master)) + return NewDerivedKeystore(DerivedKeystoreOptions{Master: master}) +} diff --git a/ntp/ntske/master_key_test.go b/ntp/ntske/master_key_test.go new file mode 100644 index 00000000..f57bf424 --- /dev/null +++ b/ntp/ntske/master_key_test.go @@ -0,0 +1,105 @@ +/* +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 ntske + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeKeyFile(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "master_key") + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +// a valid-length key file loads verbatim. +func TestLoadMasterKeyFromFileValid(t *testing.T) { + want := bytes.Repeat([]byte{0x2a}, masterKeyLen) + got, err := LoadMasterKeyFromFile(writeKeyFile(t, want)) + require.NoError(t, err) + require.Equal(t, want, got) +} + +// bytes are read verbatim: a trailing newline is not stripped. +func TestLoadMasterKeyFromFileVerbatim(t *testing.T) { + want := append(bytes.Repeat([]byte{0x2a}, masterKeyLen), '\n') + got, err := LoadMasterKeyFromFile(writeKeyFile(t, want)) + require.NoError(t, err) + require.Equal(t, want, got) + require.Len(t, got, masterKeyLen+1) +} + +// a missing file is a distinct not-exist error. +func TestLoadMasterKeyFromFileMissing(t *testing.T) { + _, err := LoadMasterKeyFromFile(filepath.Join(t.TempDir(), "nope")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// an under-length key is rejected (fail closed). +func TestLoadMasterKeyFromFileTooShort(t *testing.T) { + _, err := LoadMasterKeyFromFile(writeKeyFile(t, bytes.Repeat([]byte{1}, masterKeyMinLength-1))) + require.ErrorIs(t, err, ErrMasterKeyTooShort) +} + +// an unreadable file is a distinct permission error. +func TestLoadMasterKeyFromFilePermissionDenied(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions") + } + path := writeKeyFile(t, bytes.Repeat([]byte{0x2a}, masterKeyLen)) + require.NoError(t, os.Chmod(path, 0o000)) + _, err := LoadMasterKeyFromFile(path) + require.ErrorIs(t, err, os.ErrPermission) +} + +// fingerprint is deterministic, key-dependent, and never contains the raw key. +func TestMasterKeyFingerprint(t *testing.T) { + master := bytes.Repeat([]byte{0x2a}, masterKeyLen) + fp := MasterKeyFingerprint(master) + require.Equal(t, fp, MasterKeyFingerprint(master)) + require.NotEqual(t, fp, MasterKeyFingerprint(bytes.Repeat([]byte{0x2b}, masterKeyLen))) + require.NotContains(t, fp, string(master)) +} + +// no path selects the in-memory dev/test keystore. +func TestNewKeystoreInMemoryFallback(t *testing.T) { + ks, err := NewKeystore(KeystoreConfig{}) + require.NoError(t, err) + _, ok := ks.(*InMemoryKeystore) + require.True(t, ok, "expected *InMemoryKeystore, got %T", ks) +} + +// a master-key path selects the DerivedKeystore. +func TestNewKeystoreDerived(t *testing.T) { + path := writeKeyFile(t, bytes.Repeat([]byte{0x2a}, masterKeyLen)) + ks, err := NewKeystore(KeystoreConfig{MasterKeyPath: path}) + require.NoError(t, err) + _, ok := ks.(*DerivedKeystore) + require.True(t, ok, "expected *DerivedKeystore, got %T", ks) +} + +// a bad master-key path fails closed (no silent fallback). +func TestNewKeystoreBadPath(t *testing.T) { + _, err := NewKeystore(KeystoreConfig{MasterKeyPath: filepath.Join(t.TempDir(), "nope")}) + require.ErrorIs(t, err, os.ErrNotExist) +}