Skip to content

Commit

Permalink
Hardware key serial number attestation (#37728)
Browse files Browse the repository at this point in the history
* Add support for hardware key serial number attestation through user
trait.

* Modify AttestHardwareKey interface to return full attestation data

* Add serial number to attestation data and verify it

* Add unit test.

* Address comments.

* Don't accept partial matches.

* Fix build and test errors.
  • Loading branch information
Joerger committed Feb 12, 2024
1 parent 72023b0 commit ed8c883
Show file tree
Hide file tree
Showing 14 changed files with 266 additions and 108 deletions.
2 changes: 1 addition & 1 deletion api/types/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error {
// TODO(Joerger): DELETE IN 17.0.0
c.CheckSetPIVSlot()

if hk, err := c.GetHardwareKey(); err == nil {
if hk, err := c.GetHardwareKey(); err == nil && hk.PIVSlot != "" {
if err := keys.PIVSlot(hk.PIVSlot).Validate(); err != nil {
return trace.Wrap(err)
}
Expand Down
2 changes: 2 additions & 0 deletions api/utils/keys/hardwaresigner.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,6 @@ type AttestationData struct {
PublicKeyDER []byte `json:"public_key"`
// PrivateKeyPolicy specifies the private key policy supported by the associated private key.
PrivateKeyPolicy PrivateKeyPolicy `json:"private_key_policy"`
// SerialNumber is the serial number of the Attested hardware key.
SerialNumber uint32 `json:"serial_number"`
}
48 changes: 45 additions & 3 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2479,13 +2479,55 @@ func generateCert(a *Server, req certRequest, caType types.CertAuthType) (*proto
if err != nil {
return nil, trace.Wrap(err)
}

if !req.skipAttestation && requiredKeyPolicy != keys.PrivateKeyPolicyNone {
// verify that the required private key policy for the requesting identity
// Try to attest the given hardware key using the given attestation statement.
attestationData, err := modules.GetModules().AttestHardwareKey(ctx, a, req.attestationStatement, cryptoPubKey, sessionTTL)
if trace.IsNotFound(err) {
return nil, keys.NewPrivateKeyPolicyError(requiredKeyPolicy)
} else if err != nil {
return nil, trace.Wrap(err)
}

// verify that the required private key policy for the requested identity
// is met by the provided attestation statement.
attestedKeyPolicy, err = modules.GetModules().AttestHardwareKey(ctx, a, requiredKeyPolicy, req.attestationStatement, cryptoPubKey, sessionTTL)
if err != nil {
attestedKeyPolicy = attestationData.PrivateKeyPolicy
if err := requiredKeyPolicy.VerifyPolicy(attestedKeyPolicy); err != nil {
return nil, trace.Wrap(err)
}

if hksn, err := authPref.GetHardwareKeySerialNumberValidation(); err == nil && hksn.Enabled {
const defaultSerialNumberTraitName = "hardware_key_serial_numbers"
// Note: currently only yubikeys are supported as hardware keys. If we extend
// support to more hardware keys, we can add prefixes to serial numbers.
// Ex: solokey_12345678 or s_12345678.
// When prefixes are added, we can default to assuming that serial numbers
// without prefixes are for yubikeys, meaning there will be no backwards
// compatibility issues.
serialNumberTraitName := hksn.SerialNumberTraitName
if serialNumberTraitName == "" {
serialNumberTraitName = defaultSerialNumberTraitName
}

// Check that the attested hardware key serial number matches
// a serial number in the user's traits, if any are set.
registeredSerialNumbers, ok := req.checker.Traits()[serialNumberTraitName]
if !ok || len(registeredSerialNumbers) == 0 {
log.Debugf("user %q tried to sign in with hardware key support, but has no known hardware keys. A user's known hardware key serial numbers should be set \"in user.traits.%v\"", req.user.GetName(), serialNumberTraitName)
return nil, trace.BadParameter("cannot generate certs for user with no known hardware keys")
}

attestatedSerialNumber := strconv.Itoa(int(attestationData.SerialNumber))
// serial number traits can be a comma separated list, or a list of comma separated lists.
// e.g. [["12345678,87654321"], ["13572468"]].
if !slices.ContainsFunc(registeredSerialNumbers, func(s string) bool {
return slices.Contains(strings.Split(s, ","), attestatedSerialNumber)
}) {
log.Debugf("user %q tried to sign in with hardware key support with an unknown hardware key and was denied: YubiKey serial number %q", req.user.GetName(), attestatedSerialNumber)
return nil, trace.BadParameter("cannot generate certs for user with unknown hardware key: YubiKey serial number %q", attestatedSerialNumber)
}
}

}

clusterName, err := a.GetDomainName()
Expand Down
173 changes: 173 additions & 0 deletions lib/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2148,6 +2148,179 @@ func TestGenerateUserCertWithUserLoginState(t *testing.T) {
}, map[string][]string(traits))
}

func TestGenerateUserCertWithHardwareKeySupport(t *testing.T) {
ctx := context.Background()
p, err := newTestPack(ctx, t.TempDir())
require.NoError(t, err)

user, _, err := CreateUserAndRole(p.a, "test-user", []string{}, nil)
require.NoError(t, err)
user.SetTraits(map[string][]string{
// add in other random serial numbers to test comparison logic.
"hardware_key_serial_numbers": {"other1", "other2,12345678,other3"},
// custom trait name
"known_yubikeys": {"13572468"},
})
err = p.a.UpdateUser(ctx, user)
require.NoError(t, err)

accessInfo := services.AccessInfoFromUserState(user)
accessChecker, err := services.NewAccessChecker(accessInfo, p.clusterName.GetClusterName(), p.a)
require.NoError(t, err)

priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := keys.NewPrivateKey(priv, nil)
require.NoError(t, err)

require.NoError(t, err)
certReq := certRequest{
user: user,
checker: accessChecker,
publicKey: key.MarshalSSHPublicKey(),
}

for _, tt := range []struct {
name string
cap types.AuthPreferenceSpecV2
mockAttestationData *keys.AttestationData
assertErr require.ErrorAssertionFunc
}{
{
name: "private key policy satified",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
},
assertErr: require.NoError,
}, {
name: "no attestation data",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
assertErr: func(t require.TestingT, err error, i ...interface{}) {
require.Error(t, err, "expected private key policy error but got %v", err)
require.True(t, keys.IsPrivateKeyPolicyError(err), "expected private key policy error but got %v", err)
},
}, {
name: "private key policy not satisfied",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKey,
SerialNumber: 12345678,
},
assertErr: func(t require.TestingT, err error, i ...interface{}) {
require.Error(t, err, "expected private key policy error but got %v", err)
require.True(t, keys.IsPrivateKeyPolicyError(err), "expected private key policy error but got %v", err)
},
}, {
name: "known hardware key",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
HardwareKey: &types.HardwareKey{
SerialNumberValidation: &types.HardwareKeySerialNumberValidation{
Enabled: true,
},
},
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
SerialNumber: 12345678,
},
assertErr: require.NoError,
}, {
name: "partial serial number is unknown",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
HardwareKey: &types.HardwareKey{
SerialNumberValidation: &types.HardwareKeySerialNumberValidation{
Enabled: true,
},
},
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
SerialNumber: 1234,
},
assertErr: func(t require.TestingT, err error, i ...interface{}) {
require.True(t, trace.IsBadParameter(err), "expected bad parameter error but got %v", err)
require.ErrorContains(t, err, "unknown hardware key")
},
}, {
name: "known hardware key custom trait name",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
HardwareKey: &types.HardwareKey{
SerialNumberValidation: &types.HardwareKeySerialNumberValidation{
Enabled: true,
SerialNumberTraitName: "known_yubikeys",
},
},
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
SerialNumber: 13572468,
},
assertErr: require.NoError,
}, {
name: "unknown hardware key",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
HardwareKey: &types.HardwareKey{
SerialNumberValidation: &types.HardwareKeySerialNumberValidation{
Enabled: true,
},
},
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
SerialNumber: 87654321,
},
assertErr: func(t require.TestingT, err error, i ...interface{}) {
require.True(t, trace.IsBadParameter(err), "expected bad parameter error but got %v", err)
require.ErrorContains(t, err, "unknown hardware key")
},
}, {
name: "no known hardware keys",
cap: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
HardwareKey: &types.HardwareKey{
SerialNumberValidation: &types.HardwareKeySerialNumberValidation{
Enabled: true,
SerialNumberTraitName: "none",
},
},
},
mockAttestationData: &keys.AttestationData{
PrivateKeyPolicy: keys.PrivateKeyPolicyHardwareKeyTouch,
SerialNumber: 12345678,
},
assertErr: func(t require.TestingT, err error, i ...interface{}) {
require.True(t, trace.IsBadParameter(err), "expected bad parameter error but got %v", err)
require.ErrorContains(t, err, "no known hardware keys")
},
},
} {
t.Run(tt.name, func(t *testing.T) {
modules.SetTestModules(t, &modules.TestModules{
MockAttestationData: tt.mockAttestationData,
})

authPref, err := types.NewAuthPreference(tt.cap)
require.NoError(t, err)
err = p.a.SetAuthPreference(ctx, authPref)
require.NoError(t, err)

_, err = p.a.generateUserCert(certReq)
tt.assertErr(t, err)
})
}
}

func TestNewWebSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
Expand Down
22 changes: 16 additions & 6 deletions lib/auth/grpcserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/installers"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/sshutils"
"github.com/gravitational/teleport/lib/auth/mocku2f"
"github.com/gravitational/teleport/lib/auth/testauthority"
Expand Down Expand Up @@ -1986,7 +1987,11 @@ var requireMFATypes = []types.RequireMFAType{
}

func TestIsMFARequired(t *testing.T) {
modules.SetTestModules(t, &modules.TestModules{TestBuildType: modules.BuildEnterprise})
testModules := &modules.TestModules{
TestBuildType: modules.BuildEnterprise,
MockAttestationData: &keys.AttestationData{},
}
modules.SetTestModules(t, testModules)

ctx := context.Background()
srv := newTestTLSServer(t)
Expand Down Expand Up @@ -2022,8 +2027,6 @@ func TestIsMFARequired(t *testing.T) {
for _, roleRequireMFAType := range requireMFATypes {
roleRequireMFAType := roleRequireMFAType
t.Run(fmt.Sprintf("role=%v", roleRequireMFAType.String()), func(t *testing.T) {
t.Parallel()

user, err := types.NewUser(roleRequireMFAType.String())
require.NoError(t, err)

Expand All @@ -2040,6 +2043,14 @@ func TestIsMFARequired(t *testing.T) {
err = srv.Auth().UpsertUser(user)
require.NoError(t, err)

mfaVerifiedByHardwareKey := role.GetPrivateKeyPolicy().MFAVerified() || authPref.GetPrivateKeyPolicy().MFAVerified()
if mfaVerifiedByHardwareKey {
// Set attestated key policy to the most restrictive hardware key MFA is required.
testModules.MockAttestationData.PrivateKeyPolicy = keys.PrivateKeyPolicyHardwareKeyTouchAndPIN
} else {
testModules.MockAttestationData.PrivateKeyPolicy = keys.PrivateKeyPolicyHardwareKey
}

cl, err := srv.NewClient(TestUser(user.GetName()))
require.NoError(t, err)

Expand All @@ -2053,9 +2064,8 @@ func TestIsMFARequired(t *testing.T) {

// If auth pref or role require session MFA, and MFA is not already
// verified according to private key policy, expect MFA required.
expectRequired := (role.GetOptions().RequireMFAType.IsSessionMFARequired() || authPref.GetRequireMFAType().IsSessionMFARequired()) &&
!role.GetPrivateKeyPolicy().MFAVerified() && !authPref.GetPrivateKeyPolicy().MFAVerified()
require.Equal(t, expectRequired, resp.Required, "Expected IsMFARequired to return %v but got %v", expectRequired, resp.Required)
wantRequired := (role.GetOptions().RequireMFAType.IsSessionMFARequired() || authPref.GetRequireMFAType().IsSessionMFARequired()) && !mfaVerifiedByHardwareKey
assert.Equal(t, wantRequired, resp.Required, "Required mismatch")
})
}
})
Expand Down
6 changes: 3 additions & 3 deletions lib/modules/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ type Modules interface {
// BuildType returns build type (OSS or Enterprise)
BuildType() string
// AttestHardwareKey attests a hardware key and returns its associated private key policy.
AttestHardwareKey(context.Context, interface{}, keys.PrivateKeyPolicy, *keys.AttestationStatement, crypto.PublicKey, time.Duration) (keys.PrivateKeyPolicy, error)
AttestHardwareKey(context.Context, interface{}, *keys.AttestationStatement, crypto.PublicKey, time.Duration) (*keys.AttestationData, error)
// GenerateAccessRequestPromotions generates a list of valid promotions for given access request.
GenerateAccessRequestPromotions(context.Context, AccessResourcesGetter, types.AccessRequest) (*types.AccessRequestAllowedPromotions, error)
// GetSuggestedAccessLists generates a list of valid promotions for given access request.
Expand Down Expand Up @@ -375,9 +375,9 @@ func (p *defaultModules) IsBoringBinary() bool {
}

// AttestHardwareKey attests a hardware key.
func (p *defaultModules) AttestHardwareKey(_ context.Context, _ interface{}, _ keys.PrivateKeyPolicy, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (keys.PrivateKeyPolicy, error) {
func (p *defaultModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) {
// Default modules do not support attesting hardware keys.
return keys.PrivateKeyPolicyNone, nil
return nil, trace.NotFound("no attestation data for the given key")
}

// GenerateAccessRequestPromotions is a noop since OSS teleport does not support generating access list promotions.
Expand Down
14 changes: 9 additions & 5 deletions lib/modules/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"testing"
"time"

"github.com/gravitational/trace"

"github.com/gravitational/teleport/api/utils/keys"
)

Expand All @@ -40,7 +42,9 @@ type TestModules struct {

defaultModules

MockAttestHardwareKey func(_ context.Context, _ interface{}, policy keys.PrivateKeyPolicy, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (keys.PrivateKeyPolicy, error)
// MockAttestationData is fake attestation data to return
// during tests when hardware key support is enabled.
MockAttestationData *keys.AttestationData
}

// SetTestModules sets the value returned from GetModules to testModules
Expand Down Expand Up @@ -87,9 +91,9 @@ func (m *TestModules) BuildType() string {
}

// AttestHardwareKey attests a hardware key.
func (m *TestModules) AttestHardwareKey(ctx context.Context, obj interface{}, policy keys.PrivateKeyPolicy, as *keys.AttestationStatement, pk crypto.PublicKey, d time.Duration) (keys.PrivateKeyPolicy, error) {
if m.MockAttestHardwareKey != nil {
return m.MockAttestHardwareKey(ctx, obj, policy, as, pk, d)
func (m *TestModules) AttestHardwareKey(ctx context.Context, obj interface{}, as *keys.AttestationStatement, pk crypto.PublicKey, d time.Duration) (*keys.AttestationData, error) {
if m.MockAttestationData != nil {
return m.MockAttestationData, nil
}
return policy, nil
return nil, trace.NotFound("no attestation data for the given key")
}
2 changes: 1 addition & 1 deletion lib/services/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ type Identity interface {
UpsertKeyAttestationData(ctx context.Context, attestationData *keys.AttestationData, ttl time.Duration) error

// GetKeyAttestationData gets a verified public key attestation response.
GetKeyAttestationData(ctx context.Context, publicKey crypto.PublicKey) (*keys.AttestationData, error)
GetKeyAttestationData(ctx context.Context, pubDer []byte) (*keys.AttestationData, error)

HeadlessAuthenticationService

Expand Down
Loading

0 comments on commit ed8c883

Please sign in to comment.