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
94 changes: 94 additions & 0 deletions ntp/ntske/derived_keystore.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
174 changes: 174 additions & 0 deletions ntp/ntske/derived_keystore_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading
Loading