Skip to content
Merged
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
2 changes: 2 additions & 0 deletions api/client/webclient/webclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ type AuthenticationSettings struct {
PrivateKeyPolicy keys.PrivateKeyPolicy `json:"private_key_policy"`
// PIVSlot specifies a specific PIV slot to use with hardware key support.
PIVSlot hardwarekey.PIVSlotKeyString `json:"piv_slot"`
// PIVPINCacheTTL specifies how long to cache the user's PIV PIN.
PIVPINCacheTTL time.Duration `json:"piv_pin_cache_ttl"`
// DeviceTrust holds cluster-wide device trust settings.
DeviceTrust DeviceTrustSettings `json:"device_trust,omitempty"`
// HasMessageOfTheDay is a flag indicating that the cluster has MOTD
Expand Down
3 changes: 3 additions & 0 deletions api/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ type Profile struct {
// PIVSlot is a specific piv slot that Teleport clients should use for hardware key support.
PIVSlot hardwarekey.PIVSlotKeyString `yaml:"piv_slot"`

// PIVPINCacheTTL specifies how long to cache the user's PIV PIN.
PIVPINCacheTTL time.Duration `yaml:"piv_pin_cache_ttl"`

// MissingClusterDetails means this profile was created with limited cluster details.
// Missing cluster details should be loaded into the profile by pinging the proxy.
MissingClusterDetails bool
Expand Down
7 changes: 7 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,13 @@ message HardwareKey {
// SerialNumberValidation holds settings for hardware key serial number validation.
// By default, serial number validation is disabled.
HardwareKeySerialNumberValidation SerialNumberValidation = 2 [(gogoproto.jsontag) = "serial_number_validation,omitempty"];

// PinCacheTTL is the amount of time in nanoseconds that Teleport clients
// will cache the user's PIV PIN when hardware key PIN policy is enabled.
int64 PinCacheTTL = 3 [
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zmb3 @rosstimothy WDYT about this name change? During the RFD phase, "timeout" didn't feel right to me but "ttl" didn't come to mind until now.

(gogoproto.jsontag) = "pin_cache_ttl,omitempty",
(gogoproto.casttype) = "Duration"
];
}

message HardwareKeySerialNumberValidation {
Expand Down
10 changes: 10 additions & 0 deletions api/types/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ type AuthPreference interface {
// GetHardwareKeySerialNumberValidation returns the cluster's hardware key
// serial number validation settings.
GetHardwareKeySerialNumberValidation() (*HardwareKeySerialNumberValidation, error)
// GetPIVPINCacheTTL returns the configured piv pin cache duration for the cluster.
GetPIVPINCacheTTL() time.Duration

// GetDisconnectExpiredCert returns disconnect expired certificate setting
GetDisconnectExpiredCert() bool
Expand Down Expand Up @@ -508,6 +510,14 @@ func (c *AuthPreferenceV2) GetHardwareKeySerialNumberValidation() (*HardwareKeyS
return c.Spec.HardwareKey.SerialNumberValidation, nil
}

// GetPIVPINCacheTTL returns the configured piv pin cache duration for the cluster.
func (c *AuthPreferenceV2) GetPIVPINCacheTTL() time.Duration {
if c.Spec.HardwareKey == nil {
return 0
}
return time.Duration(c.Spec.HardwareKey.PinCacheTTL)
}

// GetDisconnectExpiredCert returns disconnect expired certificate setting
func (c *AuthPreferenceV2) GetDisconnectExpiredCert() bool {
return c.Spec.DisconnectExpiredCert.Value
Expand Down
3,973 changes: 2,002 additions & 1,971 deletions api/types/types.pb.go

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions api/utils/keys/hardwarekey/cachingprompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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) {
Comment thread
Joerger marked this conversation as resolved.
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
}
101 changes: 101 additions & 0 deletions api/utils/keys/hardwarekey/cachingprompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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)
Comment thread
Joerger marked this conversation as resolved.
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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't fmt.Sprintf("%08d", rand.Intn(100000000)) sufficient?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean instead of strconv.Itoa? strconv.Itoa seems cleaner imo, but if there's a reason to prefer fmt.Sprintf then we can do that.

If you mean instead of having the randPIN method, that's to ensure we don't repeat the same random PIN. Trying to avoid the 0.0000011% chance of a flaky test 😉

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the + 100000000 thing you're doing is to avoid leading zeroes? Sprintf will take care of that while simultaneously allowing you to use the full range of valid PINs. I don't really care about the 1 in a billion chance of a flaky test. We'll encounter 500 other flakes before we ever see this one!

return fmt.Sprintf("%08d", rand.IntN(100000000))
}
7 changes: 7 additions & 0 deletions api/utils/keys/hardwarekey/hardwarekey.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ 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].
Expand Down
6 changes: 6 additions & 0 deletions api/utils/keys/hardwarekey/service_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ 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()
Expand Down
44 changes: 29 additions & 15 deletions api/utils/keys/piv/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,27 @@ import (
// TODO(Joerger): Ensure all clients initialize [NewYubiKeyService] only once so we can
// remove this global variable.
var yubiKeyService *YubiKeyService
var yubiKeyServiceMux sync.Mutex
var yubiKeyServiceMu sync.Mutex

// YubiKeyService is a YubiKey PIV implementation of [hardwarekey.Service].
type YubiKeyService struct {
prompt hardwarekey.Prompt
promptMux sync.Mutex
prompt hardwarekey.Prompt
promptMu 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
// transaction (connection) per-yubikey limit.
yubiKeys map[uint32]*YubiKey
yubiKeysMux sync.Mutex
yubiKeys map[uint32]*YubiKey
yubiKeysMu sync.Mutex
}

// Returns a new [YubiKeyService]. If [customPrompt] is nil, the default CLI prompt will be used.
//
// Only a single service should be created for each process to ensure the cached connections
// are shared and multiple services don't compete for PIV resources.
func NewYubiKeyService(customPrompt hardwarekey.Prompt) *YubiKeyService {
yubiKeyServiceMux.Lock()
defer yubiKeyServiceMux.Unlock()
yubiKeyServiceMu.Lock()
defer yubiKeyServiceMu.Unlock()

if yubiKeyService != nil {
// If a prompt is provided, prioritize it over the existing prompt value.
Expand Down Expand Up @@ -185,8 +185,8 @@ func (s *YubiKeyService) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRe
return nil, trace.Wrap(err)
}

s.promptMux.Lock()
defer s.promptMux.Unlock()
s.promptMu.Lock()
defer s.promptMu.Unlock()

return y.sign(ctx, ref, keyInfo, s.prompt, rand, digest, opts)
}
Expand Down Expand Up @@ -239,11 +239,25 @@ 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) {
s.yubiKeysMux.Lock()
defer s.yubiKeysMux.Unlock()
s.yubiKeysMu.Lock()
defer s.yubiKeysMu.Unlock()

if y, ok := s.yubiKeys[serialNumber]; ok {
return y, nil
Expand All @@ -262,8 +276,8 @@ func (s *YubiKeyService) getYubiKey(serialNumber uint32) (*YubiKey, error) {
// 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.promptMux.Lock()
defer s.promptMux.Unlock()
s.promptMu.Lock()
defer s.promptMu.Unlock()

pin, err := s.prompt.AskPIN(ctx, hardwarekey.PINOptional, keyInfo)
if err != nil {
Expand All @@ -285,8 +299,8 @@ func (s *YubiKeyService) checkOrSetPIN(ctx context.Context, y *YubiKey, keyInfo
}

func (s *YubiKeyService) promptOverwriteSlot(ctx context.Context, msg string, keyInfo hardwarekey.ContextualKeyInfo) error {
s.promptMux.Lock()
defer s.promptMux.Unlock()
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 {
Expand Down
6 changes: 6 additions & 0 deletions api/utils/keys/piv/service_unavailable.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@ func (s *unavailableYubiKeyPIVService) Sign(_ context.Context, _ *hardwarekey.Pr
func (s *unavailableYubiKeyPIVService) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey.PIVSlotKey) (*hardwarekey.PrivateKeyRef, error) {
return nil, trace.Wrap(errPIVUnavailable)
}

func (s *unavailableYubiKeyPIVService) SetPrompt(_ hardwarekey.Prompt) {}

func (s *unavailableYubiKeyPIVService) GetPrompt() hardwarekey.Prompt {
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Optional:

Optional:

- `pin_cache_ttl` (String) PinCacheTTL is the amount of time in nanoseconds that Teleport clients will cache the user's PIV PIN when hardware key PIN policy is enabled.
- `piv_slot` (String) PIVSlot is a PIV slot that Teleport clients should use instead of the default based on private key policy. For example, "9a" or "9e".
- `serial_number_validation` (Attributes) SerialNumberValidation holds settings for hardware key serial number validation. By default, serial number validation is disabled. (see [below for nested schema](#nested-schema-for-spechardware_keyserial_number_validation))

Expand Down
Loading
Loading