feat: Hardware Key Agent - Add hardwarekey.Service interface with adapted PIV implementation (#53674)

* Add hardware key service interface; Add mock service for tests.

* Add piv implementation of hardware key service.

* Remove old yubikey parsing logic in favor of hardwarekey.PrivateKeyRef.

* Remove HardwareSigner in favor of hardwarekey.PrivateKey.

* Add test; Cleanup comment; Don't require pin/touch prompts in tests with MockHarwdareKeyService by default.

* Fix tests.

* Add TODOs.

* Use unhashed digest for WarmupHardwareKey.

* Address comments.

* Fix race condition in test service.

* Address comments.
This commit is contained in:
Brian Joerger
2025-04-09 22:14:19 +00:00
committed by GitHub
parent 6b16584c58
commit 78c19bd65c
21 changed files with 1084 additions and 407 deletions
+162
View File
@@ -0,0 +1,162 @@
// 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 defines types and interfaces for hardware private keys.
package hardwarekey
import (
"context"
"crypto"
"crypto/rand"
"encoding/json"
"io"
"github.com/gravitational/trace"
)
// Service for interfacing with hardware private keys.
type Service interface {
// NewPrivateKey creates a hardware private key that satisfies the provided [config],
// if one does not already exist, and returns a corresponding [hardwarekey.Signer].
NewPrivateKey(ctx context.Context, config PrivateKeyConfig) (*Signer, error)
// Sign performs a cryptographic signature using the specified hardware
// private key and provided signature parameters.
Sign(ctx context.Context, ref *PrivateKeyRef, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error)
// 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)
}
// Signer is a hardware key implementation of [crypto.Signer].
type Signer struct {
service Service
Ref *PrivateKeyRef
}
// NewSigner returns a [Signer] for the given service and ref.
func NewSigner(s Service, ref *PrivateKeyRef) *Signer {
return &Signer{
service: s,
Ref: ref,
}
}
// Public implements [crypto.Signer].
func (h *Signer) Public() crypto.PublicKey {
return h.Ref.PublicKey
}
// Sign implements [crypto.Signer].
func (h *Signer) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
return h.service.Sign(context.TODO(), h.Ref, rand, digest, opts)
}
// GetAttestation returns the hardware private key attestation details.
func (h *Signer) GetAttestationStatement() *AttestationStatement {
return h.Ref.AttestationStatement
}
// GetPrivateKeyPolicy returns the PrivateKeyPolicy satisfied by this key.
func (h *Signer) GetPromptPolicy() PromptPolicy {
return h.Ref.Policy
}
// WarmupHardwareKey performs a bogus sign() call to prompt the user for PIN/touch (if needed).
func (h *Signer) WarmupHardwareKey(ctx context.Context) error {
if !h.Ref.Policy.PINRequired && !h.Ref.Policy.TouchRequired {
return nil
}
// ed25519 keys only support sha512 hashing, or no hashing. Currently we don't support
// ed25519 hardware keys outside of the mocked PIV service, but we may extend support in
// the future as newer keys are being made with ed25519 support (YubiKey 5.7.x, SoloKey).
hash := crypto.SHA512
// We don't actually need to hash the digest, just make it match the hash size.
digest := make([]byte, hash.Size())
_, err := h.service.Sign(ctx, h.Ref, rand.Reader, digest, hash)
return trace.Wrap(err, "failed to perform warmup signature with hardware private key")
}
// EncodeSigner encodes the hardware key signer a format understood by other Teleport clients.
func EncodeSigner(p *Signer) ([]byte, error) {
return p.Ref.encode()
}
// DecodeSigner decodes an encoded hardware key signer for the given service.
func DecodeSigner(s Service, encodedKey []byte) (*Signer, error) {
partialRef, err := decodeKeyRef(encodedKey)
if err != nil {
return nil, trace.Wrap(err)
}
// TODO(Joerger): all fields should be encoded to the key's PEM file
// rather than being retrieved from the hardware key each time. This
// will result in a massive performance boost by avoiding re-attesting
// the key for every client call.
ref, err := s.GetFullKeyRef(partialRef.SerialNumber, partialRef.SlotKey)
if err != nil {
return nil, trace.Wrap(err)
}
return NewSigner(s, ref), nil
}
// PrivateKeyRef references a specific hardware private key.
type PrivateKeyRef struct {
// SerialNumber is the hardware key's serial number.
SerialNumber uint32 `json:"serial_number"`
// SlotKey is the key name for the hardware key PIV slot, e.g. "9a".
SlotKey PIVSlotKey `json:"slot_key"`
// PublicKey is the public key paired with the hardware private key.
PublicKey crypto.PublicKey `json:"-"`
// Policy specifies the hardware private key's PIN/touch prompt policies.
Policy PromptPolicy `json:"-"`
// 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:"-"`
}
// encode encodes a [PrivateKeyRef] to JSON.
func (r *PrivateKeyRef) encode() ([]byte, error) {
keyRefBytes, err := json.Marshal(r)
if err != nil {
return nil, trace.Wrap(err)
}
return keyRefBytes, nil
}
// decodeKeyRef decodes a [PrivateKeyRef] from JSON.
func decodeKeyRef(encodedKeyRef []byte) (*PrivateKeyRef, error) {
keyRef := &PrivateKeyRef{}
if err := json.Unmarshal(encodedKeyRef, keyRef); err != nil {
return nil, trace.Wrap(err)
}
return keyRef, nil
}
// PrivateKeyConfig contains config for creating a new hardware private key.
type PrivateKeyConfig struct {
// Policy is a prompt policy to require for the hardware private key.
Policy PromptPolicy
// CustomSlot is a specific PIV slot to generate the hardware private key in.
// If unset, the default slot for the given policy will be used.
// - !touch & !pin -> 9a
// - !touch & pin -> 9c
// - touch & pin -> 9d
// - touch & !pin -> 9e
CustomSlot PIVSlotKeyString
}
@@ -0,0 +1,139 @@
// 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 defines types and interfaces for hardware private keys.
package hardwarekey_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/sha512"
"fmt"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
"github.com/gravitational/teleport/api/utils/prompt"
)
// TestPrivateKey_EncodeDecode tests encoding and decoding a hardware key signer.
// In particular, this tests that the public key is properly encoded and that the
// contextual key info and missing key info (old client logins) is handled correctly.
func TestPrivateKey_EncodeDecode(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := hardwarekey.NewMockHardwareKeyService(nil /*prompt*/)
hwSigner, err := s.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{
Policy: hardwarekey.PromptPolicyNone,
})
require.NoError(t, err)
priv := hardwarekey.NewSigner(s, hwSigner.Ref)
encoded, err := hardwarekey.EncodeSigner(priv)
require.NoError(t, err)
decodedPriv, err := hardwarekey.DecodeSigner(s, encoded)
require.NoError(t, err)
require.Equal(t, hwSigner, decodedPriv)
}
// TestPrivateKey_Prompt tests hardware key service PIN/Touch logic with a mocked service.
func TestPrivateKey_Prompt(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := hardwarekey.NewMockHardwareKeyService(nil /*prompt*/) // a new prompt is set for each [doWithPrompt] call.
for _, policy := range []hardwarekey.PromptPolicy{
hardwarekey.PromptPolicyNone,
hardwarekey.PromptPolicyTouch,
hardwarekey.PromptPolicyPIN,
hardwarekey.PromptPolicyTouchAndPIN,
} {
t.Run(fmt.Sprintf("policy:%+v", policy), func(t *testing.T) {
type newPrivateKeyRet struct {
priv *hardwarekey.Signer
err error
}
// Creating a new hardware key requires PIN/touch.
newPrivateKeyReturn := doWithPrompt(t, s, policy, func() newPrivateKeyRet {
hwSigner, err := s.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{
Policy: policy,
})
return newPrivateKeyRet{
priv: hwSigner,
err: err,
}
})
require.NoError(t, newPrivateKeyReturn.err)
hwPriv := newPrivateKeyReturn.priv
require.NotNil(t, hwPriv)
// Signatures requires PIN/touch. Do a bogus signature.
err := doWithPrompt(t, s, policy, func() error {
hash := sha512.Sum512(make([]byte, 512))
_, err := hwPriv.Sign(rand.Reader, hash[:], crypto.SHA512)
return err
})
require.NoError(t, err)
})
}
}
func doWithPrompt[T any](t *testing.T, s *hardwarekey.MockHardwareKeyService, policy hardwarekey.PromptPolicy, fn func() T) T {
t.Helper()
// Mock a CLI prompt.
pipeReader, pipeWriter := io.Pipe()
promptReader := prompt.NewFakeReader()
s.SetPrompt(hardwarekey.NewCLIPrompt(pipeWriter, promptReader))
out := make(chan T)
go func() {
out <- fn()
}()
if policy.PINRequired {
out := make([]byte, 100)
_, err := pipeReader.Read(out)
assert.NoError(t, err)
assert.Contains(t, string(out), "Enter your YubiKey PIV PIN")
// mock service doesn't actually check the pin, it just waits for input.
promptReader.AddString("")
}
if policy.TouchRequired {
out := make([]byte, 100)
_, err := pipeReader.Read(out)
assert.NoError(t, err)
assert.Contains(t, string(out), "Tap your YubiKey")
// mock touch.
s.MockTouch()
}
select {
case out := <-out:
return out
case <-time.After(time.Second):
t.Error("failed to complete fn after prompts")
return *new(T)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ type PromptPolicy struct {
PINRequired bool
}
// Prompt provides methods to interact with a hardware [PrivateKey].
// Prompt provides methods to interact with a hardware [Signer].
type Prompt interface {
// AskPIN prompts the user for a PIN.
// The requirement tells if the PIN is required or optional.
+192
View File
@@ -0,0 +1,192 @@
// 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"
"crypto"
"crypto/ed25519"
"crypto/rand"
"io"
"sync"
"time"
"github.com/gravitational/trace"
)
// Currently Teleport does not provide a way to choose a specific hardware key,
// so we just hard code a serial number for all tests.
const serialNumber uint32 = 12345678
type fakeHardwarePrivateKey struct {
crypto.Signer
ref *PrivateKeyRef
}
// hardwareKeySlot references a specific hardware key slot on a specific hardware key.
type hardwareKeySlot struct {
serialNumber uint32
slot PIVSlotKey
}
type MockHardwareKeyService struct {
prompt Prompt
promptMu sync.Mutex
mockTouch chan struct{}
fakeHardwarePrivateKeys map[hardwareKeySlot]*fakeHardwarePrivateKey
fakeHardwarePrivateKeysMux sync.Mutex
}
// NewMockHardwareKeyService returns a [mockHardwareKeyService] for use in tests.
// If [prompt] is provided, the service will also mock PIN and touch prompts.
func NewMockHardwareKeyService(prompt Prompt) *MockHardwareKeyService {
return &MockHardwareKeyService{
prompt: prompt,
mockTouch: make(chan struct{}),
fakeHardwarePrivateKeys: map[hardwareKeySlot]*fakeHardwarePrivateKey{},
}
}
func (s *MockHardwareKeyService) NewPrivateKey(ctx context.Context, config PrivateKeyConfig) (*Signer, error) {
s.fakeHardwarePrivateKeysMux.Lock()
defer s.fakeHardwarePrivateKeysMux.Unlock()
// Get the requested or default PIV slot.
var slotKey PIVSlotKey
var err error
if config.CustomSlot != "" {
slotKey, err = config.CustomSlot.Parse()
} else {
slotKey, err = GetDefaultSlotKey(config.Policy)
}
if err != nil {
return nil, trace.Wrap(err)
}
keySlot := hardwareKeySlot{
serialNumber: serialNumber,
slot: slotKey,
}
if priv, ok := s.fakeHardwarePrivateKeys[keySlot]; ok {
return NewSigner(s, priv.ref), nil
}
// generating a new key with PIN/touch requirements requires the corresponding prompt.
if err := s.tryPrompt(ctx, config.Policy); err != nil {
return nil, trace.Wrap(err)
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, trace.Wrap(err)
}
ref := &PrivateKeyRef{
SerialNumber: serialNumber,
SlotKey: slotKey,
PublicKey: pub,
Policy: config.Policy,
// Since this is only used in tests, we will ignore the attestation statement in the end.
// We just need it to be non-nil so that it goes through the test modules implementation
// of Attest
AttestationStatement: &AttestationStatement{},
}
s.fakeHardwarePrivateKeys[keySlot] = &fakeHardwarePrivateKey{
Signer: priv,
ref: ref,
}
return NewSigner(s, ref), nil
}
// Sign performs a cryptographic signature using the specified hardware
// private key and provided signature parameters.
func (s *MockHardwareKeyService) Sign(ctx context.Context, ref *PrivateKeyRef, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
s.fakeHardwarePrivateKeysMux.Lock()
defer s.fakeHardwarePrivateKeysMux.Unlock()
priv, ok := s.fakeHardwarePrivateKeys[hardwareKeySlot{
serialNumber: serialNumber,
slot: ref.SlotKey,
}]
if !ok {
return nil, trace.NotFound("key not found in slot %d", ref.SlotKey)
}
if err := s.tryPrompt(ctx, ref.Policy); err != nil {
return nil, trace.Wrap(err)
}
return priv.Sign(rand, digest, opts)
}
func (s *MockHardwareKeyService) tryPrompt(ctx context.Context, policy PromptPolicy) error {
s.promptMu.Lock()
defer s.promptMu.Unlock()
if s.prompt == nil || (!policy.PINRequired && !policy.TouchRequired) {
return nil
}
if policy.PINRequired {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
if _, err := s.prompt.AskPIN(ctx, PINRequired); err != nil {
return trace.Wrap(err, "failed to handle pin prompt")
}
// We don't actually check the PIN for the current tests, any input is sufficient to unblock the prompt.
}
if policy.TouchRequired {
if err := s.prompt.Touch(ctx); err != nil {
return trace.Wrap(err)
}
select {
case <-s.mockTouch:
case <-time.After(100 * time.Millisecond):
return trace.Wrap(context.DeadlineExceeded, "failed to handle touch prompt")
}
}
return nil
}
func (s *MockHardwareKeyService) SetPrompt(prompt Prompt) {
s.promptMu.Lock()
defer s.promptMu.Unlock()
s.prompt = prompt
}
func (s *MockHardwareKeyService) GetFullKeyRef(serialNumber uint32, slotKey PIVSlotKey) (*PrivateKeyRef, error) {
s.fakeHardwarePrivateKeysMux.Lock()
defer s.fakeHardwarePrivateKeysMux.Unlock()
priv, ok := s.fakeHardwarePrivateKeys[hardwareKeySlot{
serialNumber: serialNumber,
slot: slotKey,
}]
if !ok {
return nil, trace.NotFound("key not found in slot %d", slotKey)
}
return priv.ref, nil
}
func (s *MockHardwareKeyService) MockTouch() {
s.mockTouch <- struct{}{}
}
+1 -1
View File
@@ -24,7 +24,7 @@ import (
// PIVSlotKey is the key reference for a specific PIV slot.
//
// See: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-73-4.pdf#page=32
type PIVSlotKey uint
type PIVSlotKey uint32
const (
pivSlotKeyBasic PIVSlotKey = 0x9a
-66
View File
@@ -1,66 +0,0 @@
/*
Copyright 2022 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 keys
import (
"crypto"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
// HardwareSigner is a crypto.Signer which can be attested as being backed by a hardware key.
// This enables the ability to enforce hardware key private key policies.
type HardwareSigner interface {
crypto.Signer
// GetAttestationStatement returns an AttestationStatement for this private key.
GetAttestationStatement() *hardwarekey.AttestationStatement
// GetPrivateKeyPolicy returns the PrivateKeyPolicy supported by this private key.
GetPrivateKeyPolicy() PrivateKeyPolicy
}
// GetAttestationStatement returns this key's AttestationStatement. If the key is
// not a hardware-backed key, this method returns nil.
func (k *PrivateKey) GetAttestationStatement() *hardwarekey.AttestationStatement {
if attestedPriv, ok := k.Signer.(HardwareSigner); ok {
return attestedPriv.GetAttestationStatement()
}
// Just return a nil attestation statement and let this key fail any attestation checks.
return nil
}
// GetPrivateKeyPolicy returns this key's PrivateKeyPolicy.
func (k *PrivateKey) GetPrivateKeyPolicy() PrivateKeyPolicy {
if attestedPriv, ok := k.Signer.(HardwareSigner); ok {
return attestedPriv.GetPrivateKeyPolicy()
}
return PrivateKeyPolicyNone
}
// IsHardware returns true if [k] is a hardware PIV key.
func (k *PrivateKey) IsHardware() bool {
_, ok := k.Signer.(HardwareSigner)
return ok
}
// AttestationData is verified attestation data for a public key.
type AttestationData struct {
// PublicKeyDER is the public key in PKIX, ASN.1 DER form.
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"`
}
-43
View File
@@ -1,43 +0,0 @@
/*
Copyright 2022 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 keys_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/utils/keys"
)
// TestNonHardwareSigner tests the HardwareSigner interface with non-hardware keys.
//
// HardwareSigners require the piv go tag and should be tested individually in tests
// like `TestGetYubiKeyPrivateKey_Interactive`.
func TestNonHardwareSigner(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := keys.NewPrivateKey(priv, nil)
require.NoError(t, err)
require.Nil(t, key.GetAttestationStatement())
require.Equal(t, keys.PrivateKeyPolicyNone, key.GetPrivateKeyPolicy())
}
+224
View File
@@ -0,0 +1,224 @@
//go:build piv && !pivtest
// 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 keys
import (
"context"
"crypto"
"io"
"sync"
"github.com/go-piv/piv-go/piv"
"github.com/gravitational/trace"
attestationv1 "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
// TODO(Joerger): Rather than using a global cache and mutexes, clients should be updated
// to create a single YubiKeyService and ensure it is reused across the program execution.
var (
// 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) limit.
//
// TODO(Joerger): This will replace the key cache in yubikey.go
yubiKeys map[uint32]*YubiKey = map[uint32]*YubiKey{}
yubiKeysMux sync.Mutex
)
// YubiKeyService is a YubiKey PIV implementation of [hardwarekey.Service].
type YubiKeyService struct {
prompt hardwarekey.Prompt
}
// Returns a new [YubiKeyService]. If [prompt] 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(prompt hardwarekey.Prompt) *YubiKeyService {
if prompt == nil {
prompt = hardwarekey.NewStdCLIPrompt()
}
return &YubiKeyService{
prompt: prompt,
}
}
// NewPrivateKey creates a hardware private key that satisfies the provided [config],
// if one does not already exist, and returns a corresponding [hardwarekey.Signer].
//
// If a customSlot is not provided in [config], the service uses the default slot for the given policy:
// - !touch & !pin -> 9a
// - !touch & pin -> 9c
// - touch & pin -> 9d
// - touch & !pin -> 9e
func (s *YubiKeyService) NewPrivateKey(ctx context.Context, config hardwarekey.PrivateKeyConfig) (*hardwarekey.Signer, error) {
var requiredKeyPolicy PrivateKeyPolicy
switch config.Policy {
case hardwarekey.PromptPolicyNone:
requiredKeyPolicy = PrivateKeyPolicyHardwareKey
case hardwarekey.PromptPolicyTouch:
requiredKeyPolicy = PrivateKeyPolicyHardwareKeyTouch
case hardwarekey.PromptPolicyPIN:
requiredKeyPolicy = PrivateKeyPolicyHardwareKeyPIN
case hardwarekey.PromptPolicyTouchAndPIN:
requiredKeyPolicy = PrivateKeyPolicyHardwareKeyTouchAndPIN
}
ykPriv, err := getOrGenerateYubiKeyPrivateKey(ctx, requiredKeyPolicy, config.CustomSlot, s.prompt)
if err != nil {
return nil, trace.Wrap(err)
}
ref := &hardwarekey.PrivateKeyRef{
SerialNumber: ykPriv.serialNumber,
SlotKey: hardwarekey.PIVSlotKey(ykPriv.pivSlot.Key),
PublicKey: ykPriv.Public(),
Policy: hardwarekey.PromptPolicy{
TouchRequired: ykPriv.attestation.TouchPolicy != piv.TouchPolicyNever,
PINRequired: ykPriv.attestation.PINPolicy != piv.PINPolicyNever,
},
AttestationStatement: &hardwarekey.AttestationStatement{
AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{
YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{
SlotCert: ykPriv.slotCert.Raw,
AttestationCert: ykPriv.attestationCert.Raw,
},
},
},
}
keyRefsMux.Lock()
defer keyRefsMux.Unlock()
keyRefs[baseKeyRef{
serialNumber: ref.SerialNumber,
slotKey: ref.SlotKey,
}] = ref
return hardwarekey.NewSigner(s, ref), nil
}
// Sign performs a cryptographic signature using the specified hardware
// private key and provided signature parameters.
func (s *YubiKeyService) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
y, err := getYubiKey(ref.SerialNumber, s.prompt)
if err != nil {
return nil, trace.Wrap(err)
}
pivSlot, err := parsePIVSlot(ref.SlotKey)
if err != nil {
return nil, trace.Wrap(err)
}
priv, err := y.getPrivateKey(pivSlot)
if err != nil {
return nil, trace.Wrap(err)
}
return priv.Sign(rand, digest, opts)
}
// 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.
func (s *YubiKeyService) SetPrompt(prompt hardwarekey.Prompt) {
s.prompt = prompt
}
// TODO(Joerger): Re-attesting the key every time we decode a hardware key signer is very resource
// intensive. This cache is a stand-in solution for the problem, which was previously handled within
// the YubiKeyPrivateKey cache that is being phased out with this change. In a follow up, the attested
// information will be saved to the key file at login time so each client will not need to re-attest
// the hardware key at all.
var (
keyRefs = map[baseKeyRef]*hardwarekey.PrivateKeyRef{}
keyRefsMux sync.Mutex
)
type baseKeyRef struct {
serialNumber uint32
slotKey hardwarekey.PIVSlotKey
}
// GetFullKeyRef gets the full [PrivateKeyRef] for an existing hardware private
// key in the given slot of the hardware key with the given serial number.
func (s *YubiKeyService) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey.PIVSlotKey) (*hardwarekey.PrivateKeyRef, error) {
keyRefsMux.Lock()
defer keyRefsMux.Unlock()
baseRef := baseKeyRef{serialNumber: serialNumber, slotKey: slotKey}
if ref, ok := keyRefs[baseRef]; ok && ref != nil {
return ref, nil
}
y, err := getYubiKey(serialNumber, s.prompt)
if err != nil {
return nil, trace.Wrap(err)
}
pivSlot, err := parsePIVSlot(slotKey)
if err != nil {
return nil, trace.Wrap(err)
}
ykPriv, err := y.getPrivateKey(pivSlot)
if err != nil {
return nil, trace.Wrap(err)
}
ref := &hardwarekey.PrivateKeyRef{
SerialNumber: serialNumber,
SlotKey: slotKey,
PublicKey: ykPriv.Public(),
Policy: hardwarekey.PromptPolicy{
TouchRequired: ykPriv.attestation.TouchPolicy != piv.TouchPolicyNever,
PINRequired: ykPriv.attestation.PINPolicy != piv.PINPolicyNever,
},
AttestationStatement: &hardwarekey.AttestationStatement{
AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{
YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{
SlotCert: ykPriv.slotCert.Raw,
AttestationCert: ykPriv.attestationCert.Raw,
},
},
},
}
keyRefs[baseRef] = ref
return ref, nil
}
// 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 getYubiKey(serialNumber uint32, prompt hardwarekey.Prompt) (*YubiKey, error) {
yubiKeysMux.Lock()
defer yubiKeysMux.Unlock()
if y, ok := yubiKeys[serialNumber]; ok {
return y, nil
}
y, err := FindYubiKey(serialNumber, prompt)
if err != nil {
return nil, trace.Wrap(err)
}
yubiKeys[y.serialNumber] = y
return y, nil
}
+34
View File
@@ -0,0 +1,34 @@
//go:build pivtest
// 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 keys
import (
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
// TODO(Joerger): Rather than using a global service, clients should be updated to
// create a single YubiKeyService and ensure it is reused across the program
// execution. At this point, it may make more sense to directly inject the mocked
// hardware key service into the test instead of using the pivtest build tag to do it.
var mockedHardwareKeyService = hardwarekey.NewMockHardwareKeyService(nil /*prompt*/)
// Returns a globally shared [hardwarekey.MockHardwareKeyService]. Test callers should
// prefer [hardwarekey.NewMockHardwareKeyService] when possible.
func NewYubiKeyService(prompt hardwarekey.Prompt) *hardwarekey.MockHardwareKeyService {
mockedHardwareKeyService.SetPrompt(prompt)
return mockedHardwareKeyService
}
@@ -1,29 +1,29 @@
//go:build piv
/*
Copyright 2022 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.
*/
// 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 keys_test
import (
"context"
"crypto/rand"
"crypto/x509/pkix"
"fmt"
"os"
"testing"
"github.com/go-piv/piv-go/piv"
pivgo "github.com/go-piv/piv-go/piv"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
@@ -34,8 +34,7 @@ import (
// TestGetYubiKeyPrivateKey_Interactive tests generation and retrieval of YubiKey private keys.
func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
// This test expects a yubiKey to be connected with default PIV
// settings and will overwrite any PIV data on the yubiKey.
// This test will overwrite any PIV data on the yubiKey.
if os.Getenv("TELEPORT_TEST_YUBIKEY_PIV") == "" {
t.Skipf("Skipping TestGenerateYubiKeyPrivateKey because TELEPORT_TEST_YUBIKEY_PIV is not set")
}
@@ -45,21 +44,33 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
}
fmt.Println("This test is interactive, tap your YubiKey when prompted.")
ctx := context.Background()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
y, err := keys.FindYubiKey(0, nil)
s := keys.NewYubiKeyService(hardwarekey.NewStdCLIPrompt())
y, err := keys.FindYubiKey(0, hardwarekey.NewStdCLIPrompt())
require.NoError(t, err)
resetYubikey(t, y)
t.Cleanup(func() { resetYubikey(t, y) })
for _, policy := range []keys.PrivateKeyPolicy{
keys.PrivateKeyPolicyHardwareKey,
keys.PrivateKeyPolicyHardwareKeyTouch,
keys.PrivateKeyPolicyHardwareKeyPIN,
keys.PrivateKeyPolicyHardwareKeyTouchAndPIN,
// Warmup the hardware key to prompt touch at the start of the test,
// rather than having this interaction later.
priv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
Policy: hardwarekey.PromptPolicy{TouchRequired: true},
})
require.NoError(t, err)
require.NoError(t, priv.WarmupHardwareKey(ctx))
for _, policy := range []hardwarekey.PromptPolicy{
hardwarekey.PromptPolicyNone,
hardwarekey.PromptPolicyTouch,
hardwarekey.PromptPolicyPIN,
hardwarekey.PromptPolicyTouchAndPIN,
} {
for _, customSlot := range []bool{true, false} {
t.Run(fmt.Sprintf("policy:%q", policy), func(t *testing.T) {
t.Run(fmt.Sprintf("policy:%+v", policy), func(t *testing.T) {
t.Run(fmt.Sprintf("custom slot:%v", customSlot), func(t *testing.T) {
resetYubikey(t, y)
setupPINPrompt(t, y)
@@ -69,21 +80,26 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
slot = "9a"
}
// GetYubiKeyPrivateKey should generate a new YubiKeyPrivateKey.
priv, err := keys.GetYubiKeyPrivateKey(ctx, policy, slot, nil)
// NewHardwarePrivateKey should generate a new hardware private key.
priv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
CustomSlot: slot,
Policy: policy,
})
require.NoError(t, err)
// test HardwareSigner methods
require.Equal(t, policy, priv.GetPrivateKeyPolicy())
require.NotNil(t, priv.GetAttestationStatement())
require.True(t, priv.IsHardware())
// Test Sign.
digest := []byte{100}
_, err = priv.Sign(rand.Reader, digest, nil)
require.NoError(t, err)
// Test bogus sign (warmup).
require.NoError(t, priv.WarmupHardwareKey(ctx))
// Another call to GetYubiKeyPrivateKey should retrieve the previously generated key.
retrievePriv, err := keys.GetYubiKeyPrivateKey(ctx, policy, slot, nil)
// Another call to NewHardwarePrivateKey should retrieve the previously generated key.
retrievePriv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
CustomSlot: slot,
Policy: policy,
})
require.NoError(t, err)
require.Equal(t, priv.Public(), retrievePriv.Public())
@@ -98,31 +114,37 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
}
func TestOverwritePrompt(t *testing.T) {
// This test expects a yubiKey to be connected with default PIV
// settings and will overwrite any PIV data on the yubiKey.
// This test will overwrite any PIV data on the yubiKey.
if os.Getenv("TELEPORT_TEST_YUBIKEY_PIV") == "" {
t.Skipf("Skipping TestGenerateYubiKeyPrivateKey because TELEPORT_TEST_YUBIKEY_PIV is not set")
}
ctx := context.Background()
y, err := keys.FindYubiKey(0, nil)
s := keys.NewYubiKeyService(hardwarekey.NewStdCLIPrompt())
y, err := keys.FindYubiKey(0, hardwarekey.NewStdCLIPrompt())
require.NoError(t, err)
resetYubikey(t, y)
t.Cleanup(func() { resetYubikey(t, y) })
// Get the default slot used for hardware_key_touch.
touchSlot := piv.SlotSignature
touchSlot := pivgo.SlotSignature
testOverwritePrompt := func(t *testing.T) {
// Fail to overwrite slot when user denies
prompt.SetStdin(prompt.NewFakeReader().AddString("n"))
_, err := keys.GetYubiKeyPrivateKey(ctx, keys.PrivateKeyPolicyHardwareKeyTouch, "" /* slot */, nil)
_, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
Policy: hardwarekey.PromptPolicy{TouchRequired: true},
})
require.True(t, trace.IsCompareFailed(err), "Expected compare failed error but got %v", err)
// Successfully overwrite slot when user accepts
prompt.SetStdin(prompt.NewFakeReader().AddString("y"))
_, err = keys.GetYubiKeyPrivateKey(ctx, keys.PrivateKeyPolicyHardwareKeyTouch, "" /* slot */, nil)
_, err = keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
Policy: hardwarekey.PromptPolicy{TouchRequired: true},
})
require.NoError(t, err)
}
@@ -140,7 +162,10 @@ func TestOverwritePrompt(t *testing.T) {
resetYubikey(t, y)
// Generate a key that does not require touch in the slot that Teleport expects to require touch.
_, err := keys.GetYubiKeyPrivateKey(ctx, keys.PrivateKeyPolicyHardwareKey, hardwarekey.PIVSlotKeyString(touchSlot.String()), nil)
_, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
CustomSlot: hardwarekey.PIVSlotKeyString(touchSlot.String()),
Policy: hardwarekey.PromptPolicy{TouchRequired: false},
})
require.NoError(t, err)
testOverwritePrompt(t)
@@ -158,7 +183,7 @@ func setupPINPrompt(t *testing.T, y *keys.YubiKey) {
// Set pin for tests.
const testPIN = "123123"
require.NoError(t, y.SetPIN(piv.DefaultPIN, testPIN))
require.NoError(t, y.SetPIN(pivgo.DefaultPIN, testPIN))
// Handle PIN prompt.
oldStdin := prompt.Stdin()
+52
View File
@@ -0,0 +1,52 @@
//go:build !piv && !pivtest
// 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 keys
import (
"context"
"crypto"
"errors"
"io"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
var errPIVUnavailable = errors.New("PIV is unavailable in current build")
func NewYubiKeyService(_ hardwarekey.Prompt) *unavailableYubiKeyPIVService {
return &unavailableYubiKeyPIVService{}
}
type unavailableYubiKeyPIVService struct{}
func (s *unavailableYubiKeyPIVService) NewPrivateKey(_ context.Context, _ hardwarekey.PrivateKeyConfig) (*hardwarekey.Signer, error) {
return nil, trace.Wrap(errPIVUnavailable)
}
// Sign performs a cryptographic signature using the specified hardware
// private key and provided signature parameters.
func (s *unavailableYubiKeyPIVService) Sign(_ context.Context, _ *hardwarekey.PrivateKeyRef, _ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) {
return nil, trace.Wrap(errPIVUnavailable)
}
func (s *unavailableYubiKeyPIVService) SetPrompt(_ hardwarekey.Prompt) {}
func (s *unavailableYubiKeyPIVService) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey.PIVSlotKey) (*hardwarekey.PrivateKeyRef, error) {
return nil, trace.Wrap(errPIVUnavailable)
}
+30
View File
@@ -199,3 +199,33 @@ func (p PrivateKeyPolicy) GetPromptPolicy() hardwarekey.PromptPolicy {
PINRequired: p.isHardwareKeyPINVerified(),
}
}
func PrivateKeyPolicyFromPromptPolicy(policy hardwarekey.PromptPolicy) PrivateKeyPolicy {
switch policy {
case hardwarekey.PromptPolicyNone:
return PrivateKeyPolicyHardwareKey
case hardwarekey.PromptPolicyTouch:
return PrivateKeyPolicyHardwareKeyTouch
case hardwarekey.PromptPolicyPIN:
return PrivateKeyPolicyHardwareKeyPIN
case hardwarekey.PromptPolicyTouchAndPIN:
return PrivateKeyPolicyHardwareKeyTouchAndPIN
default:
// unreachable case
return PrivateKeyPolicyNone
}
}
// AttestationData is verified attestation data for a public key.
type AttestationData struct {
// PublicKeyDER is the public key in PKIX, ASN.1 DER form.
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"`
}
+77 -4
View File
@@ -19,6 +19,7 @@ package keys
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
@@ -75,7 +76,7 @@ func NewPrivateKey(signer crypto.Signer, keyPEM []byte) (*PrivateKey, error) {
}
// NewSoftwarePrivateKey returns a new PrivateKey for a crypto.Signer.
// [signer] must be an *rsa.PrivateKey, *ecdsa.PrivateKey, or ed25519.PrivateKey.
// [signer] must be an *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, or *hardwarekey.PrivateKey
func NewSoftwarePrivateKey(signer crypto.Signer) (*PrivateKey, error) {
sshPub, err := ssh.NewPublicKey(signer.Public())
if err != nil {
@@ -92,6 +93,60 @@ func NewSoftwarePrivateKey(signer crypto.Signer) (*PrivateKey, error) {
}, nil
}
// NewHardwarePrivateKey uses the provided service to create a hardware private key that
// satisfies the provided [config], if one does not already exist, and returns a corresponding
// [hardwarekey.Signer] wrapped as a [PrivateKey].
func NewHardwarePrivateKey(ctx context.Context, s hardwarekey.Service, keyConfig hardwarekey.PrivateKeyConfig) (*PrivateKey, error) {
if s == nil {
return nil, trace.BadParameter("cannot create a new hardware private key without a hardware key service provided")
}
hwSigner, err := s.NewPrivateKey(ctx, keyConfig)
if err != nil {
return nil, trace.Wrap(err)
}
keyPEM, err := MarshalPrivateKey(hwSigner)
if err != nil {
return nil, trace.Wrap(err)
}
return NewPrivateKey(hwSigner, keyPEM)
}
// GetAttestationStatement returns this key's AttestationStatement. If the key is
// not a [hardwarekey.Signer], this method returns nil.
func (k *PrivateKey) GetAttestationStatement() *hardwarekey.AttestationStatement {
if hwpk, ok := k.Signer.(*hardwarekey.Signer); ok {
return hwpk.GetAttestationStatement()
}
// Just return a nil attestation statement and let this key fail any attestation checks.
return nil
}
// GetPrivateKeyPolicy returns this key's PrivateKeyPolicy.
func (k *PrivateKey) GetPrivateKeyPolicy() PrivateKeyPolicy {
if hwpk, ok := k.Signer.(*hardwarekey.Signer); ok {
return PrivateKeyPolicyFromPromptPolicy(hwpk.GetPromptPolicy())
}
return PrivateKeyPolicyNone
}
// IsHardware returns true if [k] is a [hardwarekey.Signer].
func (k *PrivateKey) IsHardware() bool {
_, ok := k.Signer.(*hardwarekey.Signer)
return ok
}
// WarmupHardwareKey checks if this is a [hardwarekey.Signer] and warms it up if it is.
func (k *PrivateKey) WarmupHardwareKey(ctx context.Context) error {
if hwpk, ok := k.Signer.(*hardwarekey.Signer); ok {
return hwpk.WarmupHardwareKey(ctx)
}
return nil
}
// SSHPublicKey returns the ssh.PublicKey representation of the public key.
func (k *PrivateKey) SSHPublicKey() ssh.PublicKey {
return k.sshPub
@@ -239,8 +294,16 @@ func ParsePrivateKey(keyPEM []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, er
switch block.Type {
case pivYubiKeyPrivateKeyType:
priv, err := parseYubiKeyPrivateKeyData(block.Bytes, appliedOpts.CustomHardwareKeyPrompt)
return priv, trace.Wrap(err, "parsing YubiKey private key")
// TODO(Joerger): Initialize the hardware key service early in the process and store
// it in the client store. This allows the process to properly share PIV connections
// and prompt logic (pin caching, etc.).
hwKeyService := NewYubiKeyService(appliedOpts.CustomHardwareKeyPrompt)
hwPrivateKey, err := hardwarekey.DecodeSigner(hwKeyService, block.Bytes)
if err != nil {
return nil, trace.Wrap(err, "failed to parse hardware key signer")
}
return NewPrivateKey(hwPrivateKey, keyPEM)
case OpenSSHPrivateKeyType:
priv, err := ssh.ParseRawPrivateKey(keyPEM)
if err != nil {
@@ -292,7 +355,7 @@ func ParsePrivateKey(keyPEM []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, er
}
// MarshalPrivateKey will return a PEM encoded crypto.Signer.
// Only supports rsa, ecdsa, and ed25519 keys.
// [key] must be an *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, or *hardwarekey.Signer
func MarshalPrivateKey(key crypto.Signer) ([]byte, error) {
switch privateKey := key.(type) {
case *rsa.PrivateKey:
@@ -311,6 +374,16 @@ func MarshalPrivateKey(key crypto.Signer) ([]byte, error) {
Bytes: der,
})
return privPEM, nil
case *hardwarekey.Signer:
encodedKey, err := hardwarekey.EncodeSigner(privateKey)
if err != nil {
return nil, trace.Wrap(err)
}
privPEM := pem.EncodeToMemory(&pem.Block{
Type: pivYubiKeyPrivateKeyType,
Bytes: encodedKey,
})
return privPEM, nil
default:
return nil, trace.BadParameter("unsupported private key type %T", key)
}
+58 -15
View File
@@ -1,3 +1,5 @@
//go:build pivtest
/*
Copyright 2022 Gravitational, Inc.
@@ -14,10 +16,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package keys
package keys_test
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
@@ -34,6 +37,9 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
func TestMarshalAndParseKey(t *testing.T) {
@@ -45,21 +51,28 @@ func TestMarshalAndParseKey(t *testing.T) {
_, edKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
// TODO(Joerger): Once the hardware key service is provided to the key parsing logic,
// use [hardwarekey.NewMockHardwareKeyService] and remove pivtest build tag
s := keys.NewYubiKeyService(nil)
hwPriv, err := s.NewPrivateKey(context.TODO(), hardwarekey.PrivateKeyConfig{})
require.NoError(t, err)
for keyType, key := range map[string]crypto.Signer{
"rsa": rsaKey,
"ecdsa": ecKey,
"ed25519": edKey,
"rsa": rsaKey,
"ecdsa": ecKey,
"ed25519": edKey,
"hardware": hwPriv,
} {
t.Run(keyType, func(t *testing.T) {
keyPEM, err := MarshalPrivateKey(key)
keyPEM, err := keys.MarshalPrivateKey(key)
require.NoError(t, err)
gotKey, err := ParsePrivateKey(keyPEM)
gotKey, err := keys.ParsePrivateKey(keyPEM)
require.NoError(t, err)
assert.Empty(t, cmp.Diff(key, gotKey.Signer), "parsed private key is not equal to the original")
pubKeyPEM, err := MarshalPublicKey(key.Public())
pubKeyPEM, err := keys.MarshalPublicKey(key.Public())
require.NoError(t, err)
gotPubKey, err := ParsePublicKey(pubKeyPEM)
gotPubKey, err := keys.ParsePublicKey(pubKeyPEM)
require.NoError(t, err)
require.Equal(t, key.Public(), gotPubKey)
assert.Empty(t, cmp.Diff(key.Public(), gotPubKey), "parsed public key is not equal to the original")
@@ -68,7 +81,7 @@ func TestMarshalAndParseKey(t *testing.T) {
}
func TestParseMismatchedPEMHeader(t *testing.T) {
rsaKey, err := ParsePrivateKey(rsaKeyPEM)
rsaKey, err := keys.ParsePrivateKey(rsaKeyPEM)
require.NoError(t, err)
rsaPKCS1DER := x509.MarshalPKCS1PrivateKey(rsaKey.Signer.(*rsa.PrivateKey))
rsaPKCS8DER, err := x509.MarshalPKCS8PrivateKey(rsaKey.Signer)
@@ -118,7 +131,7 @@ func TestParseMismatchedPEMHeader(t *testing.T) {
},
} {
t.Run(desc, func(t *testing.T) {
key, err := ParsePrivateKey(tc.pem)
key, err := keys.ParsePrivateKey(tc.pem)
require.NoError(t, err)
require.Equal(t, tc.expectKey, key.Signer)
})
@@ -144,7 +157,7 @@ func TestParseMismatchedPEMHeader(t *testing.T) {
},
} {
t.Run(desc, func(t *testing.T) {
pubKey, err := ParsePublicKey(tc.pem)
pubKey, err := keys.ParsePublicKey(tc.pem)
require.NoError(t, err)
require.Equal(t, tc.expectKey, pubKey)
})
@@ -163,7 +176,7 @@ func TestParseCorruptedKey(t *testing.T) {
} {
t.Run(tc, func(t *testing.T) {
b := pem.EncodeToMemory(&pem.Block{Type: tc, Bytes: []byte("foo")})
_, err := ParsePrivateKey(b)
_, err := keys.ParsePrivateKey(b)
require.Error(t, err)
})
}
@@ -174,7 +187,7 @@ func TestParseCorruptedKey(t *testing.T) {
} {
t.Run(tc, func(t *testing.T) {
b := pem.EncodeToMemory(&pem.Block{Type: tc, Bytes: []byte("foo")})
_, err := ParsePublicKey(b)
_, err := keys.ParsePublicKey(b)
require.Error(t, err)
})
}
@@ -208,7 +221,7 @@ func TestX509KeyPair(t *testing.T) {
expectCert, err := tls.X509KeyPair(tc.certPEM, tc.keyPEM)
require.NoError(t, err)
tlsCert, err := X509KeyPair(tc.certPEM, tc.keyPEM)
tlsCert, err := keys.X509KeyPair(tc.certPEM, tc.keyPEM)
require.NoError(t, err)
require.Empty(t, cmp.Diff(expectCert, tlsCert, cmpopts.IgnoreFields(tls.Certificate{}, "Leaf")))
@@ -268,7 +281,7 @@ func TestX509Certificate(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
cert, rawCerts, err := X509Certificate(tc.certPEM)
cert, rawCerts, err := keys.X509Certificate(tc.certPEM)
require.Len(t, rawCerts, tc.expectedLength)
tc.expectedError(t, err)
@@ -276,7 +289,37 @@ func TestX509Certificate(t *testing.T) {
tc.validateResult(t, cert)
})
}
}
// TestHardwareKeyMethods tests hardware key related methods with non-hardware keys.
//
// Testing these methods with actual hardware keys requires the piv go tag and should
// be tested individually in tests like `TestGetYubiKeyPrivateKey_Interactive`.
func TestHardwareKeyMethods(t *testing.T) {
ctx := context.Background()
// Test hardware key methods with a software key.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := keys.NewSoftwarePrivateKey(priv)
require.NoError(t, err)
require.Nil(t, key.GetAttestationStatement())
require.Equal(t, keys.PrivateKeyPolicyNone, key.GetPrivateKeyPolicy())
require.False(t, key.IsHardware())
require.NoError(t, key.WarmupHardwareKey(ctx))
// Test hardware key methods with a mocked hardware key.
s := hardwarekey.NewMockHardwareKeyService(nil /*prompt*/)
hwKey, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
Policy: hardwarekey.PromptPolicyTouch,
})
require.NoError(t, err)
require.NotNil(t, hwKey.GetAttestationStatement())
require.Equal(t, keys.PrivateKeyPolicyHardwareKeyTouch, hwKey.GetPrivateKeyPolicy())
require.True(t, hwKey.IsHardware())
require.NoError(t, hwKey.WarmupHardwareKey(ctx))
}
var (
+36 -90
View File
@@ -25,8 +25,6 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
@@ -61,19 +59,17 @@ const (
// setups, this PIN caching does not actually work as expected, so we handle it instead.
// See https://github.com/go-piv/piv-go/issues/47
var (
cachedKeys = map[piv.Slot]*PrivateKey{}
cachedKeys = map[piv.Slot]*YubiKeyPrivateKey{}
cachedKeysMu sync.Mutex
)
// getOrGenerateYubiKeyPrivateKey connects to a connected yubiKey and gets a private key
// matching the given touch requirement. This private key will either be newly generated
// or previously generated by a Teleport client and reused.
func getOrGenerateYubiKeyPrivateKey(ctx context.Context, requiredKeyPolicy PrivateKeyPolicy, slot hardwarekey.PIVSlotKeyString, prompt hardwarekey.Prompt) (*PrivateKey, error) {
func getOrGenerateYubiKeyPrivateKey(ctx context.Context, requiredKeyPolicy PrivateKeyPolicy, slot hardwarekey.PIVSlotKeyString, prompt hardwarekey.Prompt) (*YubiKeyPrivateKey, error) {
if prompt == nil {
prompt = hardwarekey.NewStdCLIPrompt()
}
cachedKeysMu.Lock()
defer cachedKeysMu.Unlock()
promptPolicy := requiredKeyPolicy.GetPromptPolicy()
@@ -95,12 +91,15 @@ func getOrGenerateYubiKeyPrivateKey(ctx context.Context, requiredKeyPolicy Priva
}
// If the program has already retrieved and cached this key, return it.
cachedKeysMu.Lock()
defer cachedKeysMu.Unlock()
if key, ok := cachedKeys[pivSlot]; ok && key.GetPrivateKeyPolicy() == requiredKeyPolicy {
return key, nil
}
// Use the first yubiKey we find.
y, err := FindYubiKey(0, prompt)
y, err := getYubiKey(0, prompt)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -145,7 +144,7 @@ func getOrGenerateYubiKeyPrivateKey(ctx context.Context, requiredKeyPolicy Priva
}
// Get the key in the slot, or generate a new one if needed.
priv, err := y.getPrivateKey(pivSlot)
priv, err := y.newYubiKeyPrivateKey(pivSlot)
switch {
case err == nil && !requiredKeyPolicy.IsSatisfiedBy(priv.GetPrivateKeyPolicy()):
// Key does not meet the required key policy, prompt the user before we overwrite the slot.
@@ -221,47 +220,6 @@ type YubiKeyPrivateKey struct {
attestation *piv.Attestation
}
// yubiKeyPrivateKeyData is marshalable data used to retrieve a specific yubiKey PIV private key.
type yubiKeyPrivateKeyData struct {
SerialNumber uint32 `json:"serial_number"`
SlotKey hardwarekey.PIVSlotKey `json:"slot_key"`
}
func parseYubiKeyPrivateKeyData(keyDataBytes []byte, prompt hardwarekey.Prompt) (*PrivateKey, error) {
if prompt == nil {
prompt = hardwarekey.NewStdCLIPrompt()
}
cachedKeysMu.Lock()
defer cachedKeysMu.Unlock()
var keyData yubiKeyPrivateKeyData
if err := json.Unmarshal(keyDataBytes, &keyData); err != nil {
return nil, trace.Wrap(err)
}
pivSlot, err := parsePIVSlot(keyData.SlotKey)
if err != nil {
return nil, trace.Wrap(err)
}
// If the program has already retrieved and cached this key, return it.
if key, ok := cachedKeys[pivSlot]; ok {
return key, nil
}
y, err := FindYubiKey(keyData.SerialNumber, prompt)
if err != nil {
return nil, trace.Wrap(err)
}
priv, err := y.getPrivateKey(pivSlot)
if err != nil {
return nil, trace.Wrap(err)
}
return priv, nil
}
// Public returns the public key corresponding to this private key.
func (y *YubiKeyPrivateKey) Public() crypto.PublicKey {
return y.slotCert.PublicKey
@@ -441,31 +399,6 @@ func abandonableSign(ctx context.Context, signer crypto.Signer, rand io.Reader,
}
}
func (y *YubiKeyPrivateKey) toPrivateKey() (*PrivateKey, error) {
keyPEM, err := y.keyPEM()
if err != nil {
return nil, trace.Wrap(err)
}
return NewPrivateKey(y, keyPEM)
}
func (y *YubiKeyPrivateKey) keyPEM() ([]byte, error) {
keyDataBytes, err := json.Marshal(yubiKeyPrivateKeyData{
SerialNumber: y.serialNumber,
SlotKey: hardwarekey.PIVSlotKey(y.pivSlot.Key),
})
if err != nil {
return nil, trace.Wrap(err)
}
return pem.EncodeToMemory(&pem.Block{
Type: pivYubiKeyPrivateKeyType,
Headers: nil,
Bytes: keyDataBytes,
}), nil
}
// GetAttestationStatement returns an AttestationStatement for this YubiKeyPrivateKey.
func (y *YubiKeyPrivateKey) GetAttestationStatement() *hardwarekey.AttestationStatement {
return &hardwarekey.AttestationStatement{
@@ -522,7 +455,7 @@ func (y *YubiKey) Reset() error {
}
// generatePrivateKeyAndCert generates a new private key and client metadata cert in the given PIV slot.
func (y *YubiKey) generatePrivateKeyAndCert(slot piv.Slot, requiredKeyPolicy PrivateKeyPolicy) (*PrivateKey, error) {
func (y *YubiKey) generatePrivateKeyAndCert(slot piv.Slot, requiredKeyPolicy PrivateKeyPolicy) (*YubiKeyPrivateKey, error) {
if err := y.generatePrivateKey(slot, requiredKeyPolicy); err != nil {
return nil, trace.Wrap(err)
}
@@ -534,7 +467,7 @@ func (y *YubiKey) generatePrivateKeyAndCert(slot piv.Slot, requiredKeyPolicy Pri
return nil, trace.Wrap(err)
}
return y.getPrivateKey(slot)
return y.newYubiKeyPrivateKey(slot)
}
// SetMetadataCertificate creates a self signed certificate and stores it in the YubiKey's
@@ -575,7 +508,26 @@ func (y *YubiKey) generatePrivateKey(slot piv.Slot, requiredKeyPolicy PrivateKey
}
// getPrivateKey gets an existing private key from the given PIV slot.
func (y *YubiKey) getPrivateKey(slot piv.Slot) (*PrivateKey, error) {
func (y *YubiKey) getPrivateKey(slot piv.Slot) (*YubiKeyPrivateKey, error) {
cachedKeysMu.Lock()
defer cachedKeysMu.Unlock()
if key, ok := cachedKeys[slot]; ok {
return key, nil
}
priv, err := y.newYubiKeyPrivateKey(slot)
if err != nil {
return nil, trace.Wrap(err)
}
cachedKeys[slot] = priv
return priv, nil
}
// newYubiKeyPrivateKey prepares a [YubiKeyPrivateKey] from an existing private key in the given PIV slot.
// This method must be called under [cachedKeysMu] lock.
func (y *YubiKey) newYubiKeyPrivateKey(slot piv.Slot) (*YubiKeyPrivateKey, error) {
slotCert, err := y.attest(slot)
if errors.Is(err, piv.ErrNotFound) {
return nil, trace.NotFound("private key in YubiKey PIV slot %q not found.", slot.String())
@@ -601,18 +553,8 @@ func (y *YubiKey) getPrivateKey(slot piv.Slot) (*PrivateKey, error) {
attestation: attestation,
}
keyPEM, err := priv.keyPEM()
if err != nil {
return nil, trace.Wrap(err)
}
key, err := NewPrivateKey(priv, keyPEM)
if err != nil {
return nil, trace.Wrap(err)
}
cachedKeys[slot] = key
return key, nil
cachedKeys[slot] = priv
return priv, nil
}
// SetPIN sets the YubiKey PIV PIN. This doesn't require user interaction like touch, just the correct old PIN.
@@ -750,8 +692,12 @@ func (c *sharedPIVConnection) reset() error {
return trace.Wrap(err)
}
defer release()
// Clear cached keys.
cachedKeys = make(map[piv.Slot]*PrivateKey)
cachedKeysMu.Lock()
defer cachedKeysMu.Unlock()
cachedKeys = make(map[piv.Slot]*YubiKeyPrivateKey)
return trace.Wrap(c.conn.Reset())
}
-37
View File
@@ -1,37 +0,0 @@
/*
Copyright 2022 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 keys
import (
"context"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
// GetYubiKeyPrivateKey attempt to retrieve a YubiKey private key matching the given hardware key policy
// from the given slot. If slot is unspecified, the default slot for the given key policy will be used.
// If the slot is empty, a new private key matching the given policy will be generated in the slot.
// - hardware_key: 9a
// - hardware_key_touch: 9c
// - hardware_key_pin: 9d
// - hardware_key_touch_pin: 9e
func GetYubiKeyPrivateKey(ctx context.Context, policy PrivateKeyPolicy, slot hardwarekey.PIVSlotKeyString, customPrompt hardwarekey.Prompt) (*PrivateKey, error) {
priv, err := getOrGenerateYubiKeyPrivateKey(ctx, policy, slot, customPrompt)
if err != nil {
return nil, trace.Wrap(err, "failed to get a YubiKey private key")
}
return priv, nil
}
-73
View File
@@ -1,73 +0,0 @@
//go:build pivtest
/*
Copyright 2024 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 keys
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"errors"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
var errPIVUnavailable = errors.New("PIV is unavailable in current build")
// Return a fake YubiKey private key.
func getOrGenerateYubiKeyPrivateKey(_ context.Context, policy PrivateKeyPolicy, _ hardwarekey.PIVSlotKeyString, _ hardwarekey.Prompt) (*PrivateKey, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, trace.Wrap(err)
}
keyPEM, err := MarshalPrivateKey(priv)
if err != nil {
return nil, trace.Wrap(err)
}
signer := &fakeYubiKeyPrivateKey{
Signer: priv,
privateKeyPolicy: policy,
}
return NewPrivateKey(signer, keyPEM)
}
func parseYubiKeyPrivateKeyData(_ []byte, _ hardwarekey.Prompt) (*PrivateKey, error) {
// TODO(Joerger): add custom marshal/unmarshal logic for fakeYubiKeyPrivateKey (if necessary).
return nil, trace.Wrap(errPIVUnavailable)
}
type fakeYubiKeyPrivateKey struct {
crypto.Signer
privateKeyPolicy PrivateKeyPolicy
}
// GetAttestationStatement returns an AttestationStatement for this private key.
func (y *fakeYubiKeyPrivateKey) GetAttestationStatement() *AttestationStatement {
// Since this is only used in tests, we will ignore the attestation statement in the end.
// We just need it to be non-nil so that it goes through the test modules implementation
// of AttestHardwareKey.
return &AttestationStatement{}
}
// GetPrivateKeyPolicy returns the PrivateKeyPolicy supported by this private key.
func (y *fakeYubiKeyPrivateKey) GetPrivateKeyPolicy() PrivateKeyPolicy {
return y.privateKeyPolicy
}
-35
View File
@@ -1,35 +0,0 @@
//go:build !piv && !pivtest
/*
Copyright 2022 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 keys
import (
"context"
"errors"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
)
var errPIVUnavailable = errors.New("PIV is unavailable in current build")
func getOrGenerateYubiKeyPrivateKey(ctx context.Context, _ PrivateKeyPolicy, _ hardwarekey.PIVSlotKeyString, _ hardwarekey.Prompt) (*PrivateKey, error) {
return nil, trace.Wrap(errPIVUnavailable)
}
func parseYubiKeyPrivateKeyData(keyDataBytes []byte, _ hardwarekey.Prompt) (*PrivateKey, error) {
return nil, trace.Wrap(errPIVUnavailable)
}
+8 -1
View File
@@ -4003,7 +4003,14 @@ func (tc *TeleportClient) GetNewLoginKeyRing(ctx context.Context) (keyRing *KeyR
if tc.PIVSlot != "" {
log.DebugContext(ctx, "Using PIV slot specified by client or server settings", "piv_slot", tc.PIVSlot)
}
priv, err := keys.GetYubiKeyPrivateKey(ctx, tc.PrivateKeyPolicy, tc.PIVSlot, tc.CustomHardwareKeyPrompt)
// TODO(Joerger): Initialize the hardware key service early in the process and store
// it in the client store. This allows the process to properly share PIV connections
// and prompt logic (pin caching, etc.).
hwks := keys.NewYubiKeyService(tc.CustomHardwareKeyPrompt)
priv, err := keys.NewHardwarePrivateKey(ctx, hwks, hardwarekey.PrivateKeyConfig{
Policy: tc.PrivateKeyPolicy.GetPromptPolicy(),
CustomSlot: tc.PIVSlot,
})
if err != nil {
return nil, trace.Wrap(err)
}
+4
View File
@@ -286,6 +286,10 @@ func (s *Storage) makeDefaultClientConfig(rootClusterURI uri.ResourceURI) *clien
cfg.InsecureSkipVerify = s.InsecureSkipVerify
cfg.AddKeysToAgent = s.AddKeysToAgent
cfg.WebauthnLogin = s.WebauthnLogin
// TODO(Joerger): Remove the rootClusterURI dependency from the teleterm prompt so that
// the storage service can share a single hardware key service+prompt. This allows the
// process to properly share PIV connections, prevents duplicate prompts, and enables
// PIN caching across clusters (if both clusters allow PIN caching).
cfg.CustomHardwareKeyPrompt = s.HardwareKeyPromptConstructor(rootClusterURI)
cfg.DTAuthnRunCeremony = dtauthn.NewCeremony().Run
cfg.DTAutoEnroll = dtenroll.AutoEnroll
+1 -1
View File
@@ -461,7 +461,7 @@ func TestHardwareKeyApp(t *testing.T) {
require.NoError(t, json.Unmarshal(confOut.Bytes(), &info))
clientCert, err = tls.LoadX509KeyPair(info.Cert, info.Key)
clientCert, err = keys.LoadX509KeyPair(info.Cert, info.Key)
require.NoError(t, err)
resp, err = testDummyAppConn(fmt.Sprintf("https://%v", proxyAddr.Addr), clientCert)