Skip to content

Commit ccefc45

Browse files
committed
fix: don't cache an incorrect PIV PIN (#54614)
* Verify PIV PIN before we cache it; Fix lint for pincache.go with build tag. * Add local test for PIN caching in the YubiKey service. * Minor fixes.
1 parent 005a89c commit ccefc45

File tree

6 files changed

+158
-47
lines changed

6 files changed

+158
-47
lines changed

api/utils/keys/hardwarekey/cliprompt.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,16 @@ func (c *cliPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement
7373
msg = fmt.Sprintf("%v to continue with command %q", msg, keyInfo.Command)
7474
}
7575

76-
password, err := prompt.Password(ctx, c.writer, c.reader, msg)
77-
return password, trace.Wrap(err)
76+
pin, err := prompt.Password(ctx, c.writer, c.reader, msg)
77+
if err != nil {
78+
return "", nil
79+
}
80+
81+
if pin == "" {
82+
pin = DefaultPIN
83+
}
84+
85+
return pin, trace.Wrap(err)
7886
}
7987

8088
// Touch prompts the user to touch the hardware key.

api/utils/keys/piv/pincache.go

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//go:build piv || pivtest
2+
13
// Copyright 2025 Gravitational, Inc.
24
//
35
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -15,14 +17,10 @@
1517
package piv
1618

1719
import (
18-
"context"
1920
"sync"
2021
"time"
2122

22-
"github.com/gravitational/trace"
2323
"github.com/jonboulle/clockwork"
24-
25-
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
2624
)
2725

2826
// pinCache is a PIN cache that supports consumers with varying required TTLs.
@@ -46,38 +44,14 @@ func newPINCache() *pinCache { //nolint:unused // used in yubikey.go with piv bu
4644
}
4745
}
4846

49-
// PromptOrGetPIN retrieves the cached PIN if set. Otherwise it prompts for the PIN and caches it.
50-
func (p *pinCache) PromptOrGetPIN(ctx context.Context, prompt hardwarekey.Prompt, requirement hardwarekey.PINPromptRequirement, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) (string, error) {
51-
// If the provided ttl is 0, it doesn't support caching, so we just prompt.
52-
if pinCacheTTL == 0 {
53-
return prompt.AskPIN(ctx, requirement, keyInfo)
54-
}
55-
56-
p.mu.Lock()
57-
defer p.mu.Unlock()
58-
59-
if pin := p.getPIN(pinCacheTTL); pin != "" {
60-
return pin, nil
61-
}
62-
63-
// Add a timeout to prevent an unanswered PIN prompt from holding the lock.
64-
const pinPromptTimeout = time.Minute
65-
ctx, cancel := context.WithTimeout(ctx, pinPromptTimeout)
66-
defer cancel()
67-
68-
pin, err := prompt.AskPIN(ctx, requirement, keyInfo)
69-
if err != nil {
70-
return "", trace.Wrap(err)
71-
}
72-
73-
p.setPIN(pin, pinCacheTTL)
74-
return pin, nil
75-
}
76-
7747
// getPIN retrieves the cached PIN. If the PIN was cached before by an amount of
7848
// time equal to the provided TTL, the PIN will not be returned.
7949
// Must be called under [p.mu] lock.
8050
func (p *pinCache) getPIN(ttl time.Duration) string {
51+
if ttl == 0 {
52+
return ""
53+
}
54+
8155
if p.pin == "" {
8256
return ""
8357
}
@@ -105,6 +79,10 @@ func (p *pinCache) getPIN(ttl time.Duration) string {
10579
// TTL would exceed that expiration.
10680
// Must be called under [p.mu] lock.
10781
func (p *pinCache) setPIN(pin string, ttl time.Duration) {
82+
if ttl == 0 {
83+
return
84+
}
85+
10886
now := p.clock.Now()
10987
expiry := now.Add(ttl)
11088

api/utils/keys/piv/pincache_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//go:build pivtest
2+
13
// Copyright 2025 Gravitational, Inc.
24
//
35
// Licensed under the Apache License, Version 2.0 (the "License");

api/utils/keys/piv/service_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"fmt"
2424
"os"
2525
"testing"
26+
"time"
2627

2728
pivgo "github.com/go-piv/piv-go/piv"
2829
"github.com/gravitational/trace"
@@ -217,6 +218,79 @@ func TestOverwritePrompt(t *testing.T) {
217218
})
218219
}
219220

221+
func TestPINCaching(t *testing.T) {
222+
// This test will overwrite any PIV data on the yubiKey.
223+
if os.Getenv("TELEPORT_TEST_YUBIKEY_PIV") == "" {
224+
t.Skipf("Skipping TestGenerateYubiKeyPrivateKey because TELEPORT_TEST_YUBIKEY_PIV is not set")
225+
}
226+
227+
ctx := context.Background()
228+
229+
promptWriter := bytes.NewBuffer([]byte{})
230+
promptReader := prompt.NewFakeReader()
231+
prompt := hardwarekey.NewCLIPrompt(promptWriter, promptReader)
232+
s := piv.NewYubiKeyService(prompt)
233+
234+
y, err := piv.FindYubiKey(0)
235+
require.NoError(t, err)
236+
237+
resetYubikey(t, y)
238+
t.Cleanup(func() { resetYubikey(t, y) })
239+
240+
// Set pin.
241+
const testPIN = "123123"
242+
require.NoError(t, y.SetPIN(pivgo.DefaultPIN, testPIN))
243+
const wrongPIN = "123321"
244+
245+
// Generate a key with PINPolicyAlways so that the PIN isn't cached internally on the YubiKey.
246+
pivSlot := pivgo.SlotAuthentication
247+
err = y.GenerateKey(pivSlot, pivgo.Key{
248+
Algorithm: pivgo.AlgorithmEC384,
249+
PINPolicy: pivgo.PINPolicyAlways,
250+
TouchPolicy: pivgo.TouchPolicyNever,
251+
})
252+
require.NoError(t, err)
253+
254+
// Providing the wrong PIN should fail without caching it.
255+
promptReader.AddString(wrongPIN)
256+
_, err = keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
257+
Policy: hardwarekey.PromptPolicyPIN,
258+
PINCacheTTL: time.Second,
259+
CustomSlot: hardwarekey.PIVSlotKeyString(pivSlot.String()),
260+
})
261+
require.Error(t, err)
262+
263+
// Retrieve the key with the right PIN and cache it.
264+
promptReader.AddString(testPIN)
265+
priv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
266+
Policy: hardwarekey.PromptPolicyPIN,
267+
PINCacheTTL: time.Second,
268+
CustomSlot: hardwarekey.PIVSlotKeyString(pivSlot.String()),
269+
})
270+
require.NoError(t, err)
271+
272+
// The PIN is cached, no prompt needed.
273+
err = priv.WarmupHardwareKey(ctx)
274+
require.NoError(t, err)
275+
276+
// Wait for the cache to expire.
277+
time.Sleep(time.Second)
278+
279+
// Signing should fail with the wrong PIN without caching it.
280+
promptReader.AddString(wrongPIN)
281+
err = priv.WarmupHardwareKey(ctx)
282+
require.Error(t, err)
283+
284+
// Signing with the right PIN should cache it.
285+
promptReader.AddString(testPIN)
286+
err = priv.WarmupHardwareKey(ctx)
287+
require.Error(t, err)
288+
289+
// The PIN is cached, no prompt needed.
290+
err = priv.WarmupHardwareKey(ctx)
291+
require.Error(t, err)
292+
}
293+
220294
// resetYubikey connects to the first yubiKey and resets it to defaults.
221295
func resetYubikey(t *testing.T, y *piv.YubiKey) {
222296
t.Helper()

api/utils/keys/piv/yubikey.go

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,8 @@ func (y *YubiKey) sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, keyI
238238
defer touchPromptDelayTimer.Reset(signTouchPromptDelay)
239239
}
240240
}
241-
pin, err := y.pinCache.PromptOrGetPIN(ctx, prompt, hardwarekey.PINRequired, keyInfo, ref.PINCacheTTL)
242-
return pin, trace.Wrap(err)
241+
242+
return y.promptPIN(ctx, prompt, hardwarekey.PINRequired, keyInfo, ref.PINCacheTTL)
243243
}
244244

245245
pinPolicy := piv.PINPolicyNever
@@ -371,7 +371,7 @@ func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPol
371371
TouchPolicy: touchPolicy,
372372
}
373373

374-
if _, err := y.conn.generateKey(piv.DefaultManagementKey, slot, opts); err != nil {
374+
if err := y.GenerateKey(slot, opts); err != nil {
375375
return nil, trace.Wrap(err)
376376
}
377377

@@ -385,6 +385,12 @@ func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPol
385385
return y.getKeyRef(slot, pinCacheTTL)
386386
}
387387

388+
// GenerateKey generates a new private key in the given PIV slot.
389+
func (y *YubiKey) GenerateKey(slot piv.Slot, opts piv.Key) error {
390+
_, err := y.conn.generateKey(piv.DefaultManagementKey, slot, opts)
391+
return trace.Wrap(err)
392+
}
393+
388394
// SetMetadataCertificate creates a self signed certificate and stores it in the YubiKey's
389395
// PIV certificate slot. This certificate is purely used as metadata to determine when a
390396
// slot is in used by a Teleport Client and is not fit to be used in cryptographic operations.
@@ -468,27 +474,62 @@ func (y *YubiKey) SetPIN(oldPin, newPin string) error {
468474
// If the user provides the default PIN, they will be prompted to set a
469475
// non-default PIN and PUK before continuing.
470476
func (y *YubiKey) checkOrSetPIN(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) error {
471-
pin, err := y.pinCache.PromptOrGetPIN(ctx, prompt, hardwarekey.PINOptional, keyInfo, pinCacheTTL)
477+
pin, err := y.promptPIN(ctx, prompt, hardwarekey.PINOptional, keyInfo, pinCacheTTL)
472478
if err != nil {
473479
return trace.Wrap(err)
474480
}
475481

476-
switch pin {
477-
case piv.DefaultPIN:
482+
if pin == piv.DefaultPIN {
478483
fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN)
479-
fallthrough
480-
case "":
481-
pin, err = y.setPINAndPUKFromDefault(ctx, prompt, keyInfo)
484+
485+
pin, err = y.setPINAndPUKFromDefault(ctx, prompt, keyInfo, pinCacheTTL)
482486
if err != nil {
483487
return trace.Wrap(err)
484488
}
485-
y.pinCache.setPIN(pin, pinCacheTTL)
486489
}
487490

488-
return trace.Wrap(y.verifyPIN(pin))
491+
return nil
489492
}
490493

491-
func (y *YubiKey) setPINAndPUKFromDefault(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo) (string, error) {
494+
// PIN (or PUK) prompts time out after 1 minute to prevent an indefinite hold of
495+
// the pin cache mutex or the exclusive PC/SC transaction.
496+
const pinPromptTimeout = time.Minute
497+
498+
func (y *YubiKey) promptPIN(ctx context.Context, prompt hardwarekey.Prompt, requirement hardwarekey.PINPromptRequirement, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) (string, error) {
499+
y.pinCache.mu.Lock()
500+
defer y.pinCache.mu.Unlock()
501+
502+
pin := y.pinCache.getPIN(pinCacheTTL)
503+
if pin != "" {
504+
return pin, nil
505+
}
506+
507+
ctx, cancel := context.WithTimeout(ctx, pinPromptTimeout)
508+
defer cancel()
509+
510+
pin, err := prompt.AskPIN(ctx, requirement, keyInfo)
511+
if err != nil {
512+
return "", trace.Wrap(err)
513+
}
514+
515+
// Verify that the PIN is correct before we cache it. This also caches it internally in the PC/SC transaction.
516+
// TODO(Joerger): In the signature pin prompt logic, we unfortunately repeat this verification
517+
// due to the way the upstream piv-go library handles PIN prompts.
518+
if err := y.verifyPIN(pin); err != nil {
519+
return "", trace.Wrap(err)
520+
}
521+
522+
y.pinCache.setPIN(pin, pinCacheTTL)
523+
return pin, nil
524+
}
525+
526+
func (y *YubiKey) setPINAndPUKFromDefault(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo, pinCacheTTL time.Duration) (string, error) {
527+
y.pinCache.mu.Lock()
528+
defer y.pinCache.mu.Unlock()
529+
530+
ctx, cancel := context.WithTimeout(ctx, pinPromptTimeout)
531+
defer cancel()
532+
492533
pinAndPUK, err := prompt.ChangePIN(ctx, keyInfo)
493534
if err != nil {
494535
return "", trace.Wrap(err)
@@ -504,10 +545,12 @@ func (y *YubiKey) setPINAndPUKFromDefault(ctx context.Context, prompt hardwareke
504545
}
505546
}
506547

548+
// unblock caches the new PIN the same way verify does.
507549
if err := y.conn.unblock(pinAndPUK.PUK, pinAndPUK.PIN); err != nil {
508550
return "", trace.Wrap(err)
509551
}
510552

553+
y.pinCache.setPIN(pinAndPUK.PIN, pinCacheTTL)
511554
return pinAndPUK.PIN, nil
512555
}
513556

lib/teleterm/daemon/hardwarekeyprompt.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,13 @@ func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement hardwareke
9595
if err != nil {
9696
return "", trace.Wrap(err)
9797
}
98-
return res.Pin, nil
98+
99+
pin := res.Pin
100+
if pin == "" {
101+
pin = hardwarekey.DefaultPIN
102+
}
103+
104+
return pin, nil
99105
}
100106

101107
// ChangePIN asks for a new PIN.

0 commit comments

Comments
 (0)