diff --git a/api/constants/constants.go b/api/constants/constants.go index 78ef3f2189ee7..652b0a0d11da1 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -540,3 +540,6 @@ const ( // GitLab CI job. EnvVarGitlabIDTokenEnvVar = "TF_TELEPORT_GITLAB_ID_TOKEN_ENV_VAR" ) + +// MaxPIVPINCacheTTL defines the maximum allowed TTL for PIV PIN client caches. +const MaxPIVPINCacheTTL = time.Hour diff --git a/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go index dadf5268e106c..685578fda3ba1 100644 --- a/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go +++ b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go @@ -25,6 +25,7 @@ package hardwarekeyagentv1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -465,7 +466,10 @@ type KeyInfo struct { Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"` // ClusterName is a Teleport cluster name that the key is associated with. // May be used to add context to PIN/touch prompts. - ClusterName string `protobuf:"bytes,5,opt,name=cluster_name,json=clusterName,proto3" json:"cluster_name,omitempty"` + ClusterName string `protobuf:"bytes,5,opt,name=cluster_name,json=clusterName,proto3" json:"cluster_name,omitempty"` + // PinCacheTtl is the amount of time that the PIN should be cached for + // PIN prompts associated with this key. A TTL of 0 means no PIN caching. + PinCacheTtl *durationpb.Duration `protobuf:"bytes,6,opt,name=pin_cache_ttl,json=pinCacheTtl,proto3" json:"pin_cache_ttl,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -535,11 +539,18 @@ func (x *KeyInfo) GetClusterName() string { return "" } +func (x *KeyInfo) GetPinCacheTtl() *durationpb.Duration { + if x != nil { + return x.PinCacheTtl + } + return nil +} + var File_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto protoreflect.FileDescriptor const file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc = "" + "\n" + - ";teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto\x12\x1cteleport.hardwarekeyagent.v1\"\r\n" + + ";teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto\x12\x1cteleport.hardwarekeyagent.v1\x1a\x1egoogle/protobuf/duration.proto\"\r\n" + "\vPingRequest\" \n" + "\fPingResponse\x12\x10\n" + "\x03pid\x18\x01 \x01(\rR\x03pid\"\x99\x02\n" + @@ -556,14 +567,15 @@ const file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc = "\x06KeyRef\x12#\n" + "\rserial_number\x18\x01 \x01(\rR\fserialNumber\x12C\n" + "\bslot_key\x18\x02 \x01(\x0e2(.teleport.hardwarekeyagent.v1.PIVSlotKeyR\aslotKey\x12$\n" + - "\x0epublic_key_der\x18\x03 \x01(\fR\fpublicKeyDer\"\xb1\x01\n" + + "\x0epublic_key_der\x18\x03 \x01(\fR\fpublicKeyDer\"\xf0\x01\n" + "\aKeyInfo\x12%\n" + "\x0etouch_required\x18\x01 \x01(\bR\rtouchRequired\x12!\n" + "\fpin_required\x18\x02 \x01(\bR\vpinRequired\x12\x1d\n" + "\n" + "proxy_host\x18\x03 \x01(\tR\tproxyHost\x12\x1a\n" + "\busername\x18\x04 \x01(\tR\busername\x12!\n" + - "\fcluster_name\x18\x05 \x01(\tR\vclusterName*~\n" + + "\fcluster_name\x18\x05 \x01(\tR\vclusterName\x12=\n" + + "\rpin_cache_ttl\x18\x06 \x01(\v2\x19.google.protobuf.DurationR\vpinCacheTtl*~\n" + "\n" + "PIVSlotKey\x12\x1c\n" + "\x18PIV_SLOT_KEY_UNSPECIFIED\x10\x00\x12\x13\n" + @@ -595,29 +607,31 @@ func file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZI var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_goTypes = []any{ - (PIVSlotKey)(0), // 0: teleport.hardwarekeyagent.v1.PIVSlotKey - (Hash)(0), // 1: teleport.hardwarekeyagent.v1.Hash - (*PingRequest)(nil), // 2: teleport.hardwarekeyagent.v1.PingRequest - (*PingResponse)(nil), // 3: teleport.hardwarekeyagent.v1.PingResponse - (*SignRequest)(nil), // 4: teleport.hardwarekeyagent.v1.SignRequest - (*Signature)(nil), // 5: teleport.hardwarekeyagent.v1.Signature - (*KeyRef)(nil), // 6: teleport.hardwarekeyagent.v1.KeyRef - (*KeyInfo)(nil), // 7: teleport.hardwarekeyagent.v1.KeyInfo + (PIVSlotKey)(0), // 0: teleport.hardwarekeyagent.v1.PIVSlotKey + (Hash)(0), // 1: teleport.hardwarekeyagent.v1.Hash + (*PingRequest)(nil), // 2: teleport.hardwarekeyagent.v1.PingRequest + (*PingResponse)(nil), // 3: teleport.hardwarekeyagent.v1.PingResponse + (*SignRequest)(nil), // 4: teleport.hardwarekeyagent.v1.SignRequest + (*Signature)(nil), // 5: teleport.hardwarekeyagent.v1.Signature + (*KeyRef)(nil), // 6: teleport.hardwarekeyagent.v1.KeyRef + (*KeyInfo)(nil), // 7: teleport.hardwarekeyagent.v1.KeyInfo + (*durationpb.Duration)(nil), // 8: google.protobuf.Duration } var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_depIdxs = []int32{ 1, // 0: teleport.hardwarekeyagent.v1.SignRequest.hash:type_name -> teleport.hardwarekeyagent.v1.Hash 6, // 1: teleport.hardwarekeyagent.v1.SignRequest.key_ref:type_name -> teleport.hardwarekeyagent.v1.KeyRef 7, // 2: teleport.hardwarekeyagent.v1.SignRequest.key_info:type_name -> teleport.hardwarekeyagent.v1.KeyInfo 0, // 3: teleport.hardwarekeyagent.v1.KeyRef.slot_key:type_name -> teleport.hardwarekeyagent.v1.PIVSlotKey - 2, // 4: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:input_type -> teleport.hardwarekeyagent.v1.PingRequest - 4, // 5: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:input_type -> teleport.hardwarekeyagent.v1.SignRequest - 3, // 6: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:output_type -> teleport.hardwarekeyagent.v1.PingResponse - 5, // 7: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:output_type -> teleport.hardwarekeyagent.v1.Signature - 6, // [6:8] is the sub-list for method output_type - 4, // [4:6] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 8, // 4: teleport.hardwarekeyagent.v1.KeyInfo.pin_cache_ttl:type_name -> google.protobuf.Duration + 2, // 5: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:input_type -> teleport.hardwarekeyagent.v1.PingRequest + 4, // 6: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:input_type -> teleport.hardwarekeyagent.v1.SignRequest + 3, // 7: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:output_type -> teleport.hardwarekeyagent.v1.PingResponse + 5, // 8: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:output_type -> teleport.hardwarekeyagent.v1.Signature + 7, // [7:9] is the sub-list for method output_type + 5, // [5:7] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_init() } diff --git a/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto b/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto index e6b1481c9efaf..e29389ee5beb9 100644 --- a/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto +++ b/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto @@ -18,6 +18,8 @@ syntax = "proto3"; package teleport.hardwarekeyagent.v1; +import "google/protobuf/duration.proto"; + option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1;hardwarekeyagentv1"; // HardwareKeyAgentService provides an agent service for hardware key (PIV) signatures. @@ -104,6 +106,9 @@ message KeyInfo { // ClusterName is a Teleport cluster name that the key is associated with. // May be used to add context to PIN/touch prompts. string cluster_name = 5; + // PinCacheTtl is the amount of time that the PIN should be cached for + // PIN prompts associated with this key. A TTL of 0 means no PIN caching. + google.protobuf.Duration pin_cache_ttl = 6; } // PIVSlotKey is the key reference for a specific PIV slot. diff --git a/api/types/authentication.go b/api/types/authentication.go index 75c98d978f71d..1f83b1386f1cb 100644 --- a/api/types/authentication.go +++ b/api/types/authentication.go @@ -877,6 +877,10 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error { c.Spec.Okta = &OktaOptions{} } + if c.GetPIVPINCacheTTL() > constants.MaxPIVPINCacheTTL { + return trace.BadParameter("piv_pin_cache_ttl cannot be larger than %s", constants.MaxPIVPINCacheTTL) + } + return nil } diff --git a/api/utils/keys/hardwarekey/cachingprompt.go b/api/utils/keys/hardwarekey/cachingprompt.go deleted file mode 100644 index 049cec11bc3b1..0000000000000 --- a/api/utils/keys/hardwarekey/cachingprompt.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2025 Gravitational, Inc. -// -// 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 hardwarekey - -import ( - "context" - "sync" - "time" - - "github.com/gravitational/trace" -) - -// NewPINCachingPrompt returns a new pin caching HardwareKeyPrompt. -// If [innerPrompt] already is a PIN caching prompt, it will be -// returned with an updated [cacheTTL]. -func NewPINCachingPrompt(innerPrompt Prompt, cacheTTL time.Duration) *PINCachingPrompt { - if p, ok := innerPrompt.(*PINCachingPrompt); ok { - p.mu.Lock() - defer p.mu.Unlock() - - p.cacheTTL = cacheTTL - return p - } - - return &PINCachingPrompt{ - Prompt: innerPrompt, - cacheTTL: cacheTTL, - } -} - -// PINCachingPrompt is a [Prompt] wrapped with PIN caching. -type PINCachingPrompt struct { - // Prompt is the inner prompt used to prompt the user for touch - // or PIN when it is not cached or is expired. - Prompt - - // mu currently protects all fields. - mu sync.Mutex - - // cacheTTL is the configured duration that a cached PIN will be valid. - cacheTTL time.Duration - // pin is the cached PIN. - pin string - // pinExpiry is the expiration time of the currently cached PIN. - pinExpiry time.Time -} - -// AskPIN returned the cached PIN if it is not expired. Otherwise, it uses -// the inner prompt to prompt the user for PIN, caching and returning it. -func (p *PINCachingPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement, keyInfo ContextualKeyInfo) (string, error) { - p.mu.Lock() - defer p.mu.Unlock() - - if p.pin != "" && time.Now().Before(p.pinExpiry) { - return p.pin, nil - } - - pin, err := p.Prompt.AskPIN(ctx, requirement, keyInfo) - if err != nil { - return "", trace.Wrap(err) - } - - p.pin = pin - p.pinExpiry = time.Now().Add(p.cacheTTL) - - return pin, nil -} - -// ChangePIN uses the inner prompt to prompt the user to change their PIN, then it caches the PIN -func (p *PINCachingPrompt) ChangePIN(ctx context.Context, keyInfo ContextualKeyInfo) (*PINAndPUK, error) { - p.mu.Lock() - defer p.mu.Unlock() - - PINAndPUK, err := p.Prompt.ChangePIN(ctx, keyInfo) - if err != nil { - return nil, trace.Wrap(err) - } - - p.pin = PINAndPUK.PIN - p.pinExpiry = time.Now().Add(p.cacheTTL) - - return PINAndPUK, nil -} diff --git a/api/utils/keys/hardwarekey/cachingprompt_test.go b/api/utils/keys/hardwarekey/cachingprompt_test.go deleted file mode 100644 index fcfecb7ff1e6e..0000000000000 --- a/api/utils/keys/hardwarekey/cachingprompt_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2025 Gravitational, Inc. -// -// 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 hardwarekey_test - -import ( - "context" - "fmt" - "math/rand/v2" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/gravitational/teleport/api/utils/keys/hardwarekey" -) - -func TestPINCachingPrompt(t *testing.T) { - ctx := context.Background() - - // Note: locally this test gets flaky around 10µs. - cacheTTL := 10 * time.Millisecond - cachingPrompt := hardwarekey.NewPINCachingPrompt(&randPINPrompt{}, cacheTTL) - - t.Run("AskPIN", func(t *testing.T) { - // prompt and cache a new PIN for 100ms. - cachedPIN, err := cachingPrompt.AskPIN(ctx, hardwarekey.PINRequired, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - timer := time.NewTimer(cacheTTL) - - // Check that the PIN remains cached. - for i := 0; i < 3; i++ { - pin, err := cachingPrompt.AskPIN(ctx, hardwarekey.PINRequired, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - require.Equal(t, cachedPIN, pin) - } - - // Check that the PIN is not cached after 100ms. - <-timer.C - pin, err := cachingPrompt.AskPIN(ctx, hardwarekey.PINRequired, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - require.NotEqual(t, cachedPIN, pin) - }) - - t.Run("ChangePIN", func(t *testing.T) { - // ChangePIN should prompt and cache a new PIN for 100ms. - pinAndPUK, err := cachingPrompt.ChangePIN(ctx, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - cachedPIN := pinAndPUK.PIN - timer := time.NewTimer(cacheTTL) - - // Check that the PIN remains cached. - for i := 0; i < 3; i++ { - pin, err := cachingPrompt.AskPIN(ctx, hardwarekey.PINRequired, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - require.Equal(t, cachedPIN, pin) - } - - // Check that the PIN is not cached after 100ms. - <-timer.C - pin, err := cachingPrompt.AskPIN(ctx, hardwarekey.PINRequired, hardwarekey.ContextualKeyInfo{}) - require.NoError(t, err) - require.NotEqual(t, cachedPIN, pin) - }) - -} - -type randPINPrompt struct{} - -func (p *randPINPrompt) AskPIN(ctx context.Context, requirement hardwarekey.PINPromptRequirement, keyInfo hardwarekey.ContextualKeyInfo) (string, error) { - return p.randPIN(), nil -} - -func (p *randPINPrompt) Touch(ctx context.Context, keyInfo hardwarekey.ContextualKeyInfo) error { - return nil -} - -func (p *randPINPrompt) ChangePIN(ctx context.Context, keyInfo hardwarekey.ContextualKeyInfo) (*hardwarekey.PINAndPUK, error) { - return &hardwarekey.PINAndPUK{ - PIN: p.randPIN(), - }, nil -} - -func (p *randPINPrompt) ConfirmSlotOverwrite(ctx context.Context, message string, keyInfo hardwarekey.ContextualKeyInfo) (bool, error) { - return false, nil -} - -func (p *randPINPrompt) randPIN() string { - return fmt.Sprintf("%08d", rand.IntN(100000000)) -} diff --git a/api/utils/keys/hardwarekey/hardwarekey.go b/api/utils/keys/hardwarekey/hardwarekey.go index 6200c68830ceb..7b3d2f77864f5 100644 --- a/api/utils/keys/hardwarekey/hardwarekey.go +++ b/api/utils/keys/hardwarekey/hardwarekey.go @@ -22,6 +22,7 @@ import ( "crypto/x509" "encoding/json" "io" + "time" "github.com/gravitational/trace" ) @@ -38,13 +39,6 @@ type Service interface { // GetFullKeyRef gets the full [PrivateKeyRef] for an existing hardware private // key in the given slot of the hardware key with the given serial number. GetFullKeyRef(serialNumber uint32, slotKey PIVSlotKey) (*PrivateKeyRef, error) - // SetPrompt sets the hardware key prompt used by the hardware key service, if applicable. - // This is used by Teleport Connect which sets the prompt later than the hardware key service, - // due to process initialization constraints. - SetPrompt(prompt Prompt) - // GetPrompt gets the hardware key prompt used by the hardware key service, or nil if - // the service does not support prompts. - GetPrompt() Prompt } // Signer is a hardware key implementation of [crypto.Signer]. @@ -131,6 +125,8 @@ type PrivateKeyRef struct { // AttestationStatement contains the hardware private key's attestation statement, which is // to attest the touch and pin requirements for this hardware private key during login. AttestationStatement *AttestationStatement `json:"attestation_statement"` + // PINCacheTTL is how long hardware key prompts should cache the PIN for this key, if at all. + PINCacheTTL time.Duration `json:"pin_cache_ttl"` } // encode encodes a [PrivateKeyRef] to JSON. @@ -246,6 +242,8 @@ type PrivateKeyConfig struct { Algorithm SignatureAlgorithm // ContextualKeyInfo contains additional info to associate with the key. ContextualKeyInfo ContextualKeyInfo + // PINCacheTTL is an option to enable PIN caching for this key with the specified TTL. + PINCacheTTL time.Duration } // ContextualKeyInfo contains contextual information associated with a hardware [PrivateKey]. diff --git a/api/utils/keys/hardwarekey/service_mock.go b/api/utils/keys/hardwarekey/service_mock.go index 2625fb629493a..0ec1a1e291133 100644 --- a/api/utils/keys/hardwarekey/service_mock.go +++ b/api/utils/keys/hardwarekey/service_mock.go @@ -120,6 +120,7 @@ func (s *MockHardwareKeyService) NewPrivateKey(ctx context.Context, config Priva // We just need it to be non-nil so that it goes through the test modules implementation // of Attest AttestationStatement: &AttestationStatement{}, + PINCacheTTL: config.PINCacheTTL, } if err := ref.Validate(); err != nil { @@ -192,12 +193,6 @@ func (s *MockHardwareKeyService) SetPrompt(prompt Prompt) { s.prompt = prompt } -func (s *MockHardwareKeyService) GetPrompt() Prompt { - s.promptMu.Lock() - defer s.promptMu.Unlock() - return s.prompt -} - // TODO(Joerger): DELETE IN v19.0.0 func (s *MockHardwareKeyService) GetFullKeyRef(serialNumber uint32, slotKey PIVSlotKey) (*PrivateKeyRef, error) { s.fakeHardwarePrivateKeysMux.Lock() diff --git a/api/utils/keys/hardwarekeyagent/agent.go b/api/utils/keys/hardwarekeyagent/agent.go index 4437bc9db14ee..fdcdb8b2f525e 100644 --- a/api/utils/keys/hardwarekeyagent/agent.go +++ b/api/utils/keys/hardwarekeyagent/agent.go @@ -31,6 +31,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "github.com/gravitational/teleport/api/constants" hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1" "github.com/gravitational/teleport/api/utils/grpc/interceptors" "github.com/gravitational/teleport/api/utils/keys/hardwarekey" @@ -95,6 +96,12 @@ func (s *agentService) Sign(ctx context.Context, req *hardwarekeyagentv1.SignReq TouchRequired: req.KeyInfo.TouchRequired, PINRequired: req.KeyInfo.PinRequired, }, + PINCacheTTL: req.KeyInfo.PinCacheTtl.AsDuration(), + } + + // Double check that the client didn't provide some bogus pin cache TTL. + if keyRef.PINCacheTTL > constants.MaxPIVPINCacheTTL { + return nil, trace.BadParameter("pin_cache_ttl cannot be larger than %s", constants.MaxPIVPINCacheTTL) } keyInfo := hardwarekey.ContextualKeyInfo{ diff --git a/api/utils/keys/hardwarekeyagent/service.go b/api/utils/keys/hardwarekeyagent/service.go index 5129c3ae9440c..20859733e10fd 100644 --- a/api/utils/keys/hardwarekeyagent/service.go +++ b/api/utils/keys/hardwarekeyagent/service.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/durationpb" hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1" "github.com/gravitational/teleport/api/utils/keys/hardwarekey" @@ -142,6 +143,7 @@ func (s *Service) agentSign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, KeyInfo: &hardwarekeyagentv1.KeyInfo{ TouchRequired: ref.Policy.TouchRequired, PinRequired: ref.Policy.PINRequired, + PinCacheTtl: durationpb.New(ref.PINCacheTTL), ProxyHost: keyInfo.ProxyHost, Username: keyInfo.Username, ClusterName: keyInfo.ClusterName, @@ -161,13 +163,3 @@ func (s *Service) agentSign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, func (s *Service) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey.PIVSlotKey) (*hardwarekey.PrivateKeyRef, error) { return s.fallbackService.GetFullKeyRef(serialNumber, slotKey) } - -// SetPrompt for the fallback service. -func (s *Service) SetPrompt(prompt hardwarekey.Prompt) { - s.fallbackService.SetPrompt(prompt) -} - -// GetPrompt for the fallback service. -func (s *Service) GetPrompt() hardwarekey.Prompt { - return s.fallbackService.GetPrompt() -} diff --git a/api/utils/keys/piv/pincache.go b/api/utils/keys/piv/pincache.go new file mode 100644 index 0000000000000..1c6b8802eda38 --- /dev/null +++ b/api/utils/keys/piv/pincache.go @@ -0,0 +1,119 @@ +// Copyright 2025 Gravitational, Inc. +// +// 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 piv + +import ( + "context" + "sync" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" +) + +// pinCache is a PIN cache that supports consumers with varying required TTLs. +type pinCache struct { + clock clockwork.Clock + + mu sync.Mutex + // pin is the cached PIN. + pin string + // pinSetAt is the time when the cached PIN was set. Used to determine whether + // the PIN should be considered expired for a specific caller's provided TTL. + pinSetAt time.Time + // pinExpiry is the expiration time of the cached PIN. + pinExpiry time.Time +} + +// newPINCache returns a new PINCache. +func newPINCache() *pinCache { //nolint:unused // used in yubikey.go with piv build constraint + return &pinCache{ + clock: clockwork.NewRealClock(), + } +} + +// PromptOrGetPIN retrieves the cached PIN if set. Otherwise it prompts for the PIN and caches it. +func (p *pinCache) PromptOrGetPIN(ctx context.Context, prompt hardwarekey.Prompt, requirement hardwarekey.PINPromptRequirement, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) (string, error) { + // If the provided ttl is 0, it doesn't support caching, so we just prompt. + if pinCacheTTL == 0 { + return prompt.AskPIN(ctx, requirement, keyInfo) + } + + p.mu.Lock() + defer p.mu.Unlock() + + if pin := p.getPIN(pinCacheTTL); pin != "" { + return pin, nil + } + + // Add a timeout to prevent an unanswered PIN prompt from holding the lock. + const pinPromptTimeout = time.Minute + ctx, cancel := context.WithTimeout(ctx, pinPromptTimeout) + defer cancel() + + pin, err := prompt.AskPIN(ctx, requirement, keyInfo) + if err != nil { + return "", trace.Wrap(err) + } + + p.setPIN(pin, pinCacheTTL) + return pin, nil +} + +// getPIN retrieves the cached PIN. If the PIN was cached before by an amount of +// time equal to the provided TTL, the PIN will not be returned. +// Must be called under [p.mu] lock. +func (p *pinCache) getPIN(ttl time.Duration) string { + if p.pin == "" { + return "" + } + + // Check if the PIN cache is expired. If it is, wipe it. + if p.clock.Now().After(p.pinExpiry) { + p.pin = "" + p.pinExpiry = time.Time{} + p.pinSetAt = time.Time{} + return "" + } + + // The PIN is cached, but does not satisfy the provided TTL of the request. + // e.g. it has been alive for 8 minutes, but the provided TTL is 5 minutes. + // For the purposes of this request, the pin should be considered expired. + if p.clock.Since(p.pinSetAt) >= ttl { + return "" + } + + return p.pin +} + +// setPIN sets the given PIN in the cache with the given TTL. If the PIN +// is already cached, the existing expiration is only updated if the given +// TTL would exceed that expiration. +// Must be called under [p.mu] lock. +func (p *pinCache) setPIN(pin string, ttl time.Duration) { + now := p.clock.Now() + expiry := now.Add(ttl) + + // Only set the expiration if it exceeds the current expiration + // or the cached PIN is being changed. + if expiry.After(p.pinExpiry) || p.pin != pin { + p.pinExpiry = expiry + } + + p.pin = pin + p.pinSetAt = now +} diff --git a/api/utils/keys/piv/pincache_test.go b/api/utils/keys/piv/pincache_test.go new file mode 100644 index 0000000000000..fadfc3ad6a5f9 --- /dev/null +++ b/api/utils/keys/piv/pincache_test.go @@ -0,0 +1,58 @@ +// Copyright 2025 Gravitational, Inc. +// +// 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 piv + +import ( + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" +) + +func TestPINCache(t *testing.T) { + clock := clockwork.NewFakeClock() + pinCache := pinCache{clock: clock} + + testPIN := "123467" + + smallTTL := time.Second + mediumTTL := time.Minute + largeTTL := time.Hour + + // Set the PIN with the medium TTL. + pinCache.setPIN(testPIN, mediumTTL) + require.Equal(t, testPIN, pinCache.getPIN(smallTTL)) + require.Equal(t, testPIN, pinCache.getPIN(mediumTTL)) + require.Equal(t, testPIN, pinCache.getPIN(largeTTL)) + + // Advancing by the small TTL should only expire the pin for the small TTL. + clock.Advance(smallTTL) + require.Zero(t, pinCache.getPIN(smallTTL)) + require.Equal(t, testPIN, pinCache.getPIN(mediumTTL)) + require.Equal(t, testPIN, pinCache.getPIN(largeTTL)) + + // Setting the PIN with the small TTL should reset the PIN's set-at time. + // The expiration time should remain tied to the medium TTL. + pinCache.setPIN(testPIN, smallTTL) + require.Equal(t, testPIN, pinCache.getPIN(smallTTL)) + require.Equal(t, testPIN, pinCache.getPIN(mediumTTL)) + + // Advancing by the medium TTL, used to set the initial cache, should expire the PIN cache. + clock.Advance(mediumTTL) + require.Zero(t, pinCache.getPIN(smallTTL)) + require.Zero(t, pinCache.getPIN(mediumTTL)) + require.Zero(t, pinCache.getPIN(largeTTL)) +} diff --git a/api/utils/keys/piv/service.go b/api/utils/keys/piv/service.go index e050d57bead16..c4b4e9d5cc1d0 100644 --- a/api/utils/keys/piv/service.go +++ b/api/utils/keys/piv/service.go @@ -23,7 +23,6 @@ import ( "errors" "fmt" "io" - "os" "sync" "github.com/go-piv/piv-go/piv" @@ -43,8 +42,12 @@ var yubiKeyServiceMu sync.Mutex // YubiKeyService is a YubiKey PIV implementation of [hardwarekey.Service]. type YubiKeyService struct { - prompt hardwarekey.Prompt - promptMu sync.Mutex + prompt hardwarekey.Prompt + + // signMu prevents prompting for PIN/touch repeatedly for concurrent signatures. + // TODO(Joerger): Rather than preventing concurrent signatures, we can make the + // PIN and touch prompts durable to concurrent signatures. + signMu sync.Mutex // yubiKeys is a shared, thread-safe [YubiKey] cache by serial number. It allows for // separate goroutines to share a YubiKey connection to work around the single PC/SC @@ -113,13 +116,13 @@ func (s *YubiKeyService) NewPrivateKey(ctx context.Context, config hardwarekey.P // If PIN is required, check that PIN and PUK are not the defaults. if config.Policy.PINRequired { - if err := s.checkOrSetPIN(ctx, y, config.ContextualKeyInfo); err != nil { + if err := y.checkOrSetPIN(ctx, s.prompt, config.ContextualKeyInfo, config.PINCacheTTL); err != nil { return nil, trace.Wrap(err) } } generatePrivateKey := func() (*hardwarekey.Signer, error) { - ref, err := y.generatePrivateKey(pivSlot, config.Policy, config.Algorithm) + ref, err := y.generatePrivateKey(pivSlot, config.Policy, config.Algorithm, config.PINCacheTTL) if err != nil { return nil, trace.Wrap(err) } @@ -148,7 +151,7 @@ func (s *YubiKeyService) NewPrivateKey(ctx context.Context, config hardwarekey.P // Check for an existing key in the slot that satisfies the required // prompt policy, or generate a new one if needed. - keyRef, err := y.getKeyRef(pivSlot) + keyRef, err := y.getKeyRef(pivSlot, config.PINCacheTTL) switch { case errors.Is(err, piv.ErrNotFound): return generatePrivateKey() @@ -182,8 +185,8 @@ func (s *YubiKeyService) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRe return nil, trace.Wrap(err) } - s.promptMu.Lock() - defer s.promptMu.Unlock() + s.signMu.Lock() + defer s.signMu.Unlock() return y.sign(ctx, ref, keyInfo, s.prompt, rand, digest, opts) } @@ -227,7 +230,7 @@ func (s *YubiKeyService) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey. return nil, trace.Wrap(err) } - ref, err := y.getKeyRef(pivSlot) + ref, err := y.getKeyRef(pivSlot, 0 /*PIN is not cached for out-of-date client keys*/) if err != nil { return nil, trace.Wrap(err) } @@ -236,20 +239,6 @@ func (s *YubiKeyService) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey. return ref, nil } -// SetPrompt sets the hardware key prompt used by the service. -func (s *YubiKeyService) SetPrompt(prompt hardwarekey.Prompt) { - s.promptMu.Lock() - defer s.promptMu.Unlock() - s.prompt = prompt -} - -// GetPrompt gets the hardware key prompt used by the service. -func (s *YubiKeyService) GetPrompt() hardwarekey.Prompt { - s.promptMu.Lock() - defer s.promptMu.Unlock() - return s.prompt -} - // Get the given YubiKey with the serial number. If the provided serialNumber is "0", // return the first YubiKey found in the smart card list. func (s *YubiKeyService) getYubiKey(serialNumber uint32) (*YubiKey, error) { @@ -269,36 +258,7 @@ func (s *YubiKeyService) getYubiKey(serialNumber uint32) (*YubiKey, error) { return y, nil } -// checkOrSetPIN prompts the user for PIN and verifies it with the YubiKey. -// If the user provides the default PIN, they will be prompted to set a -// non-default PIN and PUK before continuing. -func (s *YubiKeyService) checkOrSetPIN(ctx context.Context, y *YubiKey, keyInfo hardwarekey.ContextualKeyInfo) error { - s.promptMu.Lock() - defer s.promptMu.Unlock() - - pin, err := s.prompt.AskPIN(ctx, hardwarekey.PINOptional, keyInfo) - if err != nil { - return trace.Wrap(err) - } - - switch pin { - case piv.DefaultPIN: - fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) - fallthrough - case "": - pin, err = y.setPINAndPUKFromDefault(ctx, s.prompt, keyInfo) - if err != nil { - return trace.Wrap(err) - } - } - - return trace.Wrap(y.verifyPIN(pin)) -} - func (s *YubiKeyService) promptOverwriteSlot(ctx context.Context, msg string, keyInfo hardwarekey.ContextualKeyInfo) error { - s.promptMu.Lock() - defer s.promptMu.Unlock() - promptQuestion := fmt.Sprintf("%v\nWould you like to overwrite this slot's private key and certificate?", msg) if confirmed, confirmErr := s.prompt.ConfirmSlotOverwrite(ctx, promptQuestion, keyInfo); confirmErr != nil { return trace.Wrap(confirmErr) diff --git a/api/utils/keys/piv/service_unavailable.go b/api/utils/keys/piv/service_unavailable.go index 886912361cc17..0b1e59836876f 100644 --- a/api/utils/keys/piv/service_unavailable.go +++ b/api/utils/keys/piv/service_unavailable.go @@ -50,7 +50,3 @@ func (s *unavailableYubiKeyPIVService) GetFullKeyRef(serialNumber uint32, slotKe } func (s *unavailableYubiKeyPIVService) SetPrompt(_ hardwarekey.Prompt) {} - -func (s *unavailableYubiKeyPIVService) GetPrompt() hardwarekey.Prompt { - return nil -} diff --git a/api/utils/keys/piv/yubikey.go b/api/utils/keys/piv/yubikey.go index d0a6e02ad70f3..d00f27f569f16 100644 --- a/api/utils/keys/piv/yubikey.go +++ b/api/utils/keys/piv/yubikey.go @@ -30,6 +30,7 @@ import ( "fmt" "io" "math/big" + "os" "strings" "sync" "time" @@ -58,6 +59,8 @@ type YubiKey struct { serialNumber uint32 // version is the YubiKey's version. version piv.Version + // pinCache can be used to skip PIN prompts for keys that have PIN caching enabled. + pinCache *pinCache } // FindYubiKey finds a YubiKey PIV card by serial number. If the provided @@ -111,6 +114,7 @@ func findYubiKeyCards() ([]string, error) { func newYubiKey(card string) (*YubiKey, error) { y := &YubiKey{ + pinCache: newPINCache(), conn: &sharedPIVConnection{ card: card, }, @@ -234,8 +238,8 @@ func (y *YubiKey) sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, keyI defer touchPromptDelayTimer.Reset(signTouchPromptDelay) } } - pass, err := prompt.AskPIN(ctx, hardwarekey.PINRequired, keyInfo) - return pass, trace.Wrap(err) + pin, err := y.pinCache.PromptOrGetPIN(ctx, prompt, hardwarekey.PINRequired, keyInfo, ref.PINCacheTTL) + return pin, trace.Wrap(err) } pinPolicy := piv.PINPolicyNever @@ -334,7 +338,7 @@ func (y *YubiKey) Reset() error { } // generatePrivateKey generates a new private key in the given PIV slot. -func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPolicy, algorithm hardwarekey.SignatureAlgorithm) (*hardwarekey.PrivateKeyRef, error) { +func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPolicy, algorithm hardwarekey.SignatureAlgorithm, pinCacheTTL time.Duration) (*hardwarekey.PrivateKeyRef, error) { touchPolicy := piv.TouchPolicyNever if policy.TouchRequired { touchPolicy = piv.TouchPolicyCached @@ -378,7 +382,7 @@ func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPol return nil, trace.Wrap(err) } - return y.getKeyRef(slot) + return y.getKeyRef(slot, pinCacheTTL) } // SetMetadataCertificate creates a self signed certificate and stores it in the YubiKey's @@ -422,7 +426,7 @@ func (y *YubiKey) attestKey(slot piv.Slot) (slotCert *x509.Certificate, attCert return slotCert, attCert, att, nil } -func (y *YubiKey) getKeyRef(slot piv.Slot) (*hardwarekey.PrivateKeyRef, error) { +func (y *YubiKey) getKeyRef(slot piv.Slot, pinCacheTTL time.Duration) (*hardwarekey.PrivateKeyRef, error) { slotCert, attCert, att, err := y.attestKey(slot) if err != nil { return nil, trace.Wrap(err) @@ -444,6 +448,7 @@ func (y *YubiKey) getKeyRef(slot piv.Slot) (*hardwarekey.PrivateKeyRef, error) { }, }, }, + PINCacheTTL: pinCacheTTL, } if err := ref.Validate(); err != nil { @@ -459,6 +464,30 @@ func (y *YubiKey) SetPIN(oldPin, newPin string) error { return trace.Wrap(err) } +// checkOrSetPIN prompts the user for PIN and verifies it with the YubiKey. +// If the user provides the default PIN, they will be prompted to set a +// non-default PIN and PUK before continuing. +func (y *YubiKey) checkOrSetPIN(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) error { + pin, err := y.pinCache.PromptOrGetPIN(ctx, prompt, hardwarekey.PINOptional, keyInfo, pinCacheTTL) + if err != nil { + return trace.Wrap(err) + } + + switch pin { + case piv.DefaultPIN: + fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) + fallthrough + case "": + pin, err = y.setPINAndPUKFromDefault(ctx, prompt, keyInfo) + if err != nil { + return trace.Wrap(err) + } + y.pinCache.setPIN(pin, pinCacheTTL) + } + + return trace.Wrap(y.verifyPIN(pin)) +} + func (y *YubiKey) setPINAndPUKFromDefault(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo) (string, error) { pinAndPUK, err := prompt.ChangePIN(ctx, keyInfo) if err != nil { diff --git a/lib/client/api.go b/lib/client/api.go index 1b636665d0bf0..8d0b1c1268beb 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -4017,6 +4017,7 @@ func (tc *TeleportClient) GetNewLoginKeyRing(ctx context.Context) (keyRing *KeyR Username: tc.Username, ClusterName: tc.SiteName, }, + PINCacheTTL: tc.PIVPINCacheTTL, }) if err != nil { return nil, trace.Wrap(err) diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 208204220eccd..5dd1a298259e2 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -4420,12 +4420,6 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err fmt.Printf("WARNING: Failed to load tsh profile for %q: %v\n", proxy, profileErr) } - if c.PIVPINCacheTTL != 0 { - innerPrompt := c.ClientStore.HardwareKeyService.GetPrompt() - pinCachingPrompt := hardwarekey.NewPINCachingPrompt(innerPrompt, c.PIVPINCacheTTL) - c.ClientStore.HardwareKeyService.SetPrompt(pinCachingPrompt) - } - if cf.Username != "" { c.Username = cf.Username }