From a54c57d7fe1293066107bd9e2d399ab2a0903628 Mon Sep 17 00:00:00 2001 From: Sofia Scalzo Date: Wed, 5 Aug 2026 05:02:19 -0700 Subject: [PATCH] Add DerivedKeystore for fleet-wide cookie sealing Summary: Fleet-wide NTS cookie keystore: a pure HKDF key-derivation primitive plus the DerivedKeystore that uses it, so NTS-KE and NTP can seal and open cookies on different hosts from one shared master, with no shared live state and no hot-path RPC. - deriveCookieKey(master, id) = HKDF-SHA256(master, salt=BE32(id), info="fbnts-cookie-seal-v1") -> a masterKeyLen (64-octet) key for the AES-SIV-CMAC-512 master AEAD. Pure: no clock, state, or I/O. - DerivedKeystore implements the Keystore interface: SealCookie derives a key from the master (fixed cookieKeyID) and reuses the existing AES-SIV envelope; OpenCookie re-derives from the cookie's Key ID and opens it. No mutex, since the master is immutable and derivation is pure. Time-based rotation/windowing is deferred to a later change. Reviewed By: leoleovich Differential Revision: D114721800 --- ntp/ntske/derived_keystore.go | 94 ++++++++++++++++ ntp/ntske/derived_keystore_test.go | 174 +++++++++++++++++++++++++++++ ntp/ntske/keystore.go | 69 +++++++----- 3 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 ntp/ntske/derived_keystore.go create mode 100644 ntp/ntske/derived_keystore_test.go diff --git a/ntp/ntske/derived_keystore.go b/ntp/ntske/derived_keystore.go new file mode 100644 index 00000000..0b5c7ace --- /dev/null +++ b/ntp/ntske/derived_keystore.go @@ -0,0 +1,94 @@ +/* +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" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + + "github.com/facebook/time/ntp/protocol" +) + +// Cookie sealing key: K_id = HKDF-SHA256(master, salt=BE32(id), info=label). +const ( + cookieSealInfoLabel = "fbnts-cookie-seal-v1" // HKDF info; domain separation + masterKeyMinLength = 32 // security rests on master entropy + cookieKeyID uint32 = 0 // fixed Key ID; rotation/windowing deferred +) + +// ErrMasterKeyTooShort is returned when the master is under masterKeyMinLength octets. +var ErrMasterKeyTooShort = errors.New("ntske: master key too short") + +// deriveCookieKey derives the masterKeyLen-octet sealing key for key id. Pure +// (no clock/state/I/O) so any host reconstructs it from (master, id) alone. +func deriveCookieKey(master []byte, id uint32) ([]byte, error) { + if len(master) < masterKeyMinLength { + return nil, fmt.Errorf("%w: got %d octets, need >= %d", + ErrMasterKeyTooShort, len(master), masterKeyMinLength) + } + salt := binary.BigEndian.AppendUint32(nil, id) + key, err := hkdf.Key(sha256.New, master, salt, cookieSealInfoLabel, masterKeyLen) + if err != nil { + return nil, fmt.Errorf("ntske: derive cookie key: %w", err) + } + return key, nil +} + +// DerivedKeystore seals and opens cookies with keys derived on demand from one +// immutable master, so a cookie sealed on one host opens on another. +type DerivedKeystore struct { + master []byte +} + +var _ Keystore = (*DerivedKeystore)(nil) + +type DerivedKeystoreOptions struct { + Master []byte // >= masterKeyMinLength octets +} + +// NewDerivedKeystore validates the master and returns a keystore ready to seal +// and open cookies. The master is cloned; it is never mutated afterwards. +func NewDerivedKeystore(opts DerivedKeystoreOptions) (*DerivedKeystore, error) { + if len(opts.Master) < masterKeyMinLength { + return nil, fmt.Errorf("%w: got %d octets, need >= %d", + ErrMasterKeyTooShort, len(opts.Master), masterKeyMinLength) + } + return &DerivedKeystore{master: bytes.Clone(opts.Master)}, nil +} + +// SealCookie derives the fixed-id sealing key and seals c2s || s2c via the shared +// envelope (see sealEnvelope). Nonce randomness comes from crypto/rand. +func (ks *DerivedKeystore) SealCookie(aeadID protocol.AEADAlgorithm, c2s, s2c []byte) ([]byte, error) { + sealingKey, err := deriveCookieKey(ks.master, cookieKeyID) + if err != nil { + return nil, err + } + return sealEnvelope(rand.Reader, cookieKeyID, sealingKey, aeadID, c2s, s2c) +} + +// OpenCookie re-derives the sealing key from the cookie's Key ID and opens it via +// the shared envelope (see openEnvelope). +func (ks *DerivedKeystore) OpenCookie(cookie []byte) (protocol.AEADAlgorithm, []byte, []byte, error) { + return openEnvelope(cookie, func(id uint32) ([]byte, error) { + return deriveCookieKey(ks.master, id) + }) +} diff --git a/ntp/ntske/derived_keystore_test.go b/ntp/ntske/derived_keystore_test.go new file mode 100644 index 00000000..cc8253b2 --- /dev/null +++ b/ntp/ntske/derived_keystore_test.go @@ -0,0 +1,174 @@ +/* +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" + "sync" + "testing" + + "github.com/facebook/time/ntp/protocol" + "github.com/stretchr/testify/require" +) + +var ( + testC2S = bytes.Repeat([]byte{0x11}, 64) + testS2C = bytes.Repeat([]byte{0x22}, 64) +) + +func testMaster() []byte { return bytes.Repeat([]byte{0x2a}, masterKeyLen) } + +func newDerivedKeystore(t *testing.T) *DerivedKeystore { + t.Helper() + ks, err := NewDerivedKeystore(DerivedKeystoreOptions{Master: testMaster()}) + require.NoError(t, err) + return ks +} + +// same (master, id) must always derive the same key. +func TestDeriveCookieKeyDeterministic(t *testing.T) { + master := bytes.Repeat([]byte{0x01}, masterKeyMinLength) + k1, err := deriveCookieKey(master, 42) + require.NoError(t, err) + k2, err := deriveCookieKey(master, 42) + require.NoError(t, err) + require.Equal(t, k1, k2) +} + +// a different id must derive a different key. +func TestDeriveCookieKeyDistinctPerID(t *testing.T) { + master := bytes.Repeat([]byte{0x01}, masterKeyMinLength) + k1, err := deriveCookieKey(master, 1) + require.NoError(t, err) + k2, err := deriveCookieKey(master, 2) + require.NoError(t, err) + require.NotEqual(t, k1, k2) +} + +// a different master must derive a different key. +func TestDeriveCookieKeyDistinctPerMaster(t *testing.T) { + k1, err := deriveCookieKey(bytes.Repeat([]byte{0x01}, masterKeyMinLength), 7) + require.NoError(t, err) + k2, err := deriveCookieKey(bytes.Repeat([]byte{0x02}, masterKeyMinLength), 7) + require.NoError(t, err) + require.NotEqual(t, k1, k2) +} + +// output must be masterKeyLen octets (feeds AES-SIV-CMAC-512). +func TestDeriveCookieKeyOutputLength(t *testing.T) { + k, err := deriveCookieKey(bytes.Repeat([]byte{0x01}, masterKeyMinLength), 0) + require.NoError(t, err) + require.Len(t, k, masterKeyLen) +} + +// a master under the minimum length must be rejected. +func TestDeriveCookieKeyShortMaster(t *testing.T) { + _, err := deriveCookieKey(bytes.Repeat([]byte{0x01}, masterKeyMinLength-1), 0) + require.ErrorIs(t, err, ErrMasterKeyTooShort) +} + +// core claim: a cookie sealed by one keystore opens in another sharing only the master. +func TestDerivedKeystoreCrossProcessRoundTrip(t *testing.T) { + cookie, err := newDerivedKeystore(t).SealCookie(protocol.AEADAESSIVCMAC512, testC2S, testS2C) + require.NoError(t, err) + aeadID, c2s, s2c, err := newDerivedKeystore(t).OpenCookie(cookie) + require.NoError(t, err) + require.Equal(t, protocol.AEADAESSIVCMAC512, aeadID) + require.Equal(t, testC2S, c2s) + require.Equal(t, testS2C, s2c) +} + +// cookie length must be unchanged from InMemoryKeystore (session algorithm inferred from it). +func TestDerivedKeystoreCookieLengths(t *testing.T) { + ks := newDerivedKeystore(t) + for _, tc := range []struct { + aead protocol.AEADAlgorithm + keyLen, wantLen int + }{ + {protocol.AEADAES128GCMSIV, 16, 68}, + {protocol.AEADAESSIVCMAC512, 64, 164}, + } { + cookie, err := ks.SealCookie(tc.aead, bytes.Repeat([]byte{1}, tc.keyLen), bytes.Repeat([]byte{2}, tc.keyLen)) + require.NoError(t, err) + require.Len(t, cookie, tc.wantLen) + } +} + +// a cookie sealed under a different master must fail verification. +func TestDerivedKeystoreWrongMaster(t *testing.T) { + cookie, err := newDerivedKeystore(t).SealCookie(protocol.AEADAESSIVCMAC512, testC2S, testS2C) + require.NoError(t, err) + opener, err := NewDerivedKeystore(DerivedKeystoreOptions{Master: bytes.Repeat([]byte{0x99}, masterKeyLen)}) + require.NoError(t, err) + _, _, _, err = opener.OpenCookie(cookie) + require.ErrorIs(t, err, ErrCookieVerify) +} + +// tampering with the key id, nonce, or ciphertext must fail verification. +func TestDerivedKeystoreTamper(t *testing.T) { + ks := newDerivedKeystore(t) + cookie, err := ks.SealCookie(protocol.AEADAESSIVCMAC512, testC2S, testS2C) + require.NoError(t, err) + for _, tc := range []struct { + name string + pos int + }{ + {"key id", 0}, + {"nonce", cookieKeyIDLen}, + {"ciphertext", len(cookie) - 1}, + } { + t.Run(tc.name, func(t *testing.T) { + bad := bytes.Clone(cookie) + bad[tc.pos] ^= 0xff + _, _, _, err := ks.OpenCookie(bad) + require.ErrorIs(t, err, ErrCookieVerify) + }) + } +} + +// constructing with a master under the minimum length must be rejected. +func TestNewDerivedKeystoreShortMaster(t *testing.T) { + _, err := NewDerivedKeystore(DerivedKeystoreOptions{Master: bytes.Repeat([]byte{1}, masterKeyMinLength-1)}) + require.ErrorIs(t, err, ErrMasterKeyTooShort) +} + +// concurrent seal+open must be race-free (no shared mutable state). Each goroutine +// writes to its own slot; results are checked with require after wg.Wait(). +func TestDerivedKeystoreConcurrent(t *testing.T) { + ks := newDerivedKeystore(t) + const n = 50 + errs := make([]error, n) + gotC2S := make([][]byte, n) + gotS2C := make([][]byte, n) + var wg sync.WaitGroup + for i := range n { + wg.Go(func() { + cookie, err := ks.SealCookie(protocol.AEADAESSIVCMAC512, testC2S, testS2C) + if err != nil { + errs[i] = err + return + } + _, gotC2S[i], gotS2C[i], errs[i] = ks.OpenCookie(cookie) + }) + } + wg.Wait() + for i := range n { + require.NoError(t, errs[i]) + require.Equal(t, testC2S, gotC2S[i]) + require.Equal(t, testS2C, gotS2C[i]) + } +} diff --git a/ntp/ntske/keystore.go b/ntp/ntske/keystore.go index 6bf3b590..19bba2fd 100644 --- a/ntp/ntske/keystore.go +++ b/ntp/ntske/keystore.go @@ -30,7 +30,7 @@ import ( ) /* -Cookie keystore (RFC 8915 §6). +Cookie keystore (RFC 8915 section 6). An NTS cookie is server-opaque state: the client stores it verbatim and echoes it back, and only the server can open it. It carries the two session keys (C2S and S2C) negotiated during the NTS-KE handshake so the server does not @@ -46,7 +46,7 @@ Wire format (chrony-compatible; see chrony's nts_ke_server.c): | | +---------------------------------------------------------------+ | | - ~ AES-SIV ciphertext = tag(16) ‖ (C2S ‖ S2C) ~ + ~ AES-SIV ciphertext = tag(16) || (C2S || S2C) ~ | | +---------------------------------------------------------------+ - Key ID: big-endian identifier of the master key that sealed this cookie. @@ -58,8 +58,8 @@ Wire format (chrony-compatible; see chrony's nts_ke_server.c): we fold this random nonce into the SIV as associated data, so identical key material produces distinct cookies. This matches chrony, which passes the nonce as the SIV nonce component. -- Ciphertext: tink's AES-SIV output — the 16-octet synthetic IV (tag) followed - by the encrypted C2S ‖ S2C key material. +- Ciphertext: tink's AES-SIV output - the 16-octet synthetic IV (tag) followed + by the encrypted C2S || S2C key material. The *session* AEAD algorithm (the one the client will use for NTP, negotiated via NTS-KE) is NOT stored in the cookie. It is inferred from the total cookie length, because the C2S/S2C key length is fixed per algorithm: @@ -224,10 +224,34 @@ func (ks *InMemoryKeystore) Rotate() error { return nil } -// SealCookie encrypts c2s ‖ s2c under the current master key. c2s and s2c must -// both have the key length required by aeadID. +// SealCookie encrypts c2s || s2c under the current master key via the shared +// envelope (see sealEnvelope). c2s and s2c must both have the key length required +// by aeadID. func (ks *InMemoryKeystore) SealCookie(aeadID protocol.AEADAlgorithm, c2s, s2c []byte) ([]byte, error) { - // validate aeadID and keyLen + ks.mu.RLock() + keyID := ks.current + master := ks.ring[keyID] + ks.mu.RUnlock() + return sealEnvelope(ks.rand, keyID, master, aeadID, c2s, s2c) +} + +// OpenCookie decrypts a cookie via the shared envelope (see openEnvelope), +// resolving the master by Key ID from the ring. +func (ks *InMemoryKeystore) OpenCookie(cookie []byte) (protocol.AEADAlgorithm, []byte, []byte, error) { + return openEnvelope(cookie, func(keyID uint32) ([]byte, error) { + ks.mu.RLock() + master, ok := ks.ring[keyID] + ks.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("%w: keyID=%d", ErrUnknownKeyID, keyID) + } + return master, nil + }) +} + +// sealEnvelope seals c2s || s2c under sealingKey and assembles the cookie +// [Key ID][Nonce][AES-SIV ciphertext]; nonce bytes are read from rnd. +func sealEnvelope(rnd io.Reader, keyID uint32, sealingKey []byte, aeadID protocol.AEADAlgorithm, c2s, s2c []byte) ([]byte, error) { keyLen, err := aeadIDToKeyLen(aeadID) if err != nil { return nil, err @@ -237,21 +261,16 @@ func (ks *InMemoryKeystore) SealCookie(aeadID protocol.AEADAlgorithm, c2s, s2c [ ErrKeyLength, aeadID, keyLen, len(c2s), len(s2c)) } nonce := make([]byte, cookieNonceLen) - if _, err := io.ReadFull(ks.rand, nonce); err != nil { + if _, err := io.ReadFull(rnd, nonce); err != nil { return nil, fmt.Errorf("ntske: read nonce: %w", err) } - ks.mu.RLock() - keyID := ks.current - master := ks.ring[keyID] - ks.mu.RUnlock() - aead, err := nts.NewAEAD(masterAEADID, master) + aead, err := nts.NewAEAD(masterAEADID, sealingKey) if err != nil { return nil, fmt.Errorf("ntske: master aead: %w", err) } plaintext := make([]byte, 0, 2*keyLen) plaintext = append(plaintext, c2s...) plaintext = append(plaintext, s2c...) - _, ct, err := aead.Seal(nonce, plaintext) if err != nil { return nil, fmt.Errorf("ntske: seal cookie: %w", err) @@ -263,9 +282,9 @@ func (ks *InMemoryKeystore) SealCookie(aeadID protocol.AEADAlgorithm, c2s, s2c [ return out, nil } -// OpenCookie decrypts a cookie and returns the negotiated session aeadID with -// the C2S and S2C keys. -func (ks *InMemoryKeystore) OpenCookie(cookie []byte) (protocol.AEADAlgorithm, []byte, []byte, error) { +// openEnvelope validates a cookie, resolves its sealing key via sealingKeyFor, +// and decrypts it, returning the session aeadID and the C2S/S2C keys. +func openEnvelope(cookie []byte, sealingKeyFor func(keyID uint32) ([]byte, error)) (protocol.AEADAlgorithm, []byte, []byte, error) { aeadID, err := CookieAEADID(cookie) if err != nil { return 0, nil, nil, err @@ -275,15 +294,13 @@ func (ks *InMemoryKeystore) OpenCookie(cookie []byte) (protocol.AEADAlgorithm, [ return 0, nil, nil, err } keyID := binary.BigEndian.Uint32(cookie[0:cookieKeyIDLen]) + sealingKey, err := sealingKeyFor(keyID) + if err != nil { + return 0, nil, nil, err + } nonce := cookie[cookieKeyIDLen : cookieKeyIDLen+cookieNonceLen] ct := cookie[cookieKeyIDLen+cookieNonceLen:] - ks.mu.RLock() - master, ok := ks.ring[keyID] - ks.mu.RUnlock() - if !ok { - return 0, nil, nil, fmt.Errorf("%w: keyID=%d", ErrUnknownKeyID, keyID) - } - aead, err := nts.NewAEAD(masterAEADID, master) + aead, err := nts.NewAEAD(masterAEADID, sealingKey) if err != nil { return 0, nil, nil, fmt.Errorf("ntske: master aead: %w", err) } @@ -295,9 +312,7 @@ func (ks *InMemoryKeystore) OpenCookie(cookie []byte) (protocol.AEADAlgorithm, [ return 0, nil, nil, fmt.Errorf("%w: decrypted %d octets, want %d", ErrCookieMalformed, len(pt), 2*keyLen) } - c2s := bytes.Clone(pt[:keyLen]) - s2c := bytes.Clone(pt[keyLen:]) - return aeadID, c2s, s2c, nil + return aeadID, bytes.Clone(pt[:keyLen]), bytes.Clone(pt[keyLen:]), nil } // CookieAEADID reports the negotiated session AEAD algorithm ID encoded by a