From 78c19bd65cecfc53d3946f56e4dfdab2ab2ff03e Mon Sep 17 00:00:00 2001 From: Brian Joerger Date: Wed, 9 Apr 2025 15:14:19 -0700 Subject: [PATCH] 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. --- api/utils/keys/hardwarekey/hardwarekey.go | 162 +++++++++++++ .../keys/hardwarekey/hardwarekey_test.go | 139 +++++++++++ api/utils/keys/hardwarekey/prompt.go | 2 +- api/utils/keys/hardwarekey/service_mock.go | 192 +++++++++++++++ api/utils/keys/hardwarekey/slot.go | 2 +- api/utils/keys/hardwaresigner.go | 66 ------ api/utils/keys/hardwaresigner_test.go | 43 ---- api/utils/keys/piv_service.go | 224 ++++++++++++++++++ api/utils/keys/piv_service_fake.go | 34 +++ .../{yubikey_test.go => piv_service_test.go} | 105 ++++---- api/utils/keys/piv_service_unavailable.go | 52 ++++ api/utils/keys/policy.go | 30 +++ api/utils/keys/privatekey.go | 81 ++++++- api/utils/keys/privatekey_test.go | 73 ++++-- api/utils/keys/yubikey.go | 126 +++------- api/utils/keys/yubikey_common.go | 37 --- api/utils/keys/yubikey_fake.go | 73 ------ api/utils/keys/yubikey_other.go | 35 --- lib/client/api.go | 9 +- lib/teleterm/clusters/storage.go | 4 + tool/tsh/common/hardware_key_test.go | 2 +- 21 files changed, 1084 insertions(+), 407 deletions(-) create mode 100644 api/utils/keys/hardwarekey/hardwarekey.go create mode 100644 api/utils/keys/hardwarekey/hardwarekey_test.go create mode 100644 api/utils/keys/hardwarekey/service_mock.go delete mode 100644 api/utils/keys/hardwaresigner.go delete mode 100644 api/utils/keys/hardwaresigner_test.go create mode 100644 api/utils/keys/piv_service.go create mode 100644 api/utils/keys/piv_service_fake.go rename api/utils/keys/{yubikey_test.go => piv_service_test.go} (54%) create mode 100644 api/utils/keys/piv_service_unavailable.go delete mode 100644 api/utils/keys/yubikey_common.go delete mode 100644 api/utils/keys/yubikey_fake.go delete mode 100644 api/utils/keys/yubikey_other.go diff --git a/api/utils/keys/hardwarekey/hardwarekey.go b/api/utils/keys/hardwarekey/hardwarekey.go new file mode 100644 index 00000000000..23900b0c0c3 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey.go @@ -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 +} diff --git a/api/utils/keys/hardwarekey/hardwarekey_test.go b/api/utils/keys/hardwarekey/hardwarekey_test.go new file mode 100644 index 00000000000..88eba0288c9 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey_test.go @@ -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) + } +} diff --git a/api/utils/keys/hardwarekey/prompt.go b/api/utils/keys/hardwarekey/prompt.go index 986a0c9311d..991a38c0309 100644 --- a/api/utils/keys/hardwarekey/prompt.go +++ b/api/utils/keys/hardwarekey/prompt.go @@ -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. diff --git a/api/utils/keys/hardwarekey/service_mock.go b/api/utils/keys/hardwarekey/service_mock.go new file mode 100644 index 00000000000..088b75175cf --- /dev/null +++ b/api/utils/keys/hardwarekey/service_mock.go @@ -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{}{} +} diff --git a/api/utils/keys/hardwarekey/slot.go b/api/utils/keys/hardwarekey/slot.go index 52e2e9eac8a..744b55631ee 100644 --- a/api/utils/keys/hardwarekey/slot.go +++ b/api/utils/keys/hardwarekey/slot.go @@ -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 diff --git a/api/utils/keys/hardwaresigner.go b/api/utils/keys/hardwaresigner.go deleted file mode 100644 index b806717be24..00000000000 --- a/api/utils/keys/hardwaresigner.go +++ /dev/null @@ -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"` -} diff --git a/api/utils/keys/hardwaresigner_test.go b/api/utils/keys/hardwaresigner_test.go deleted file mode 100644 index 48bb77f9f2a..00000000000 --- a/api/utils/keys/hardwaresigner_test.go +++ /dev/null @@ -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()) -} diff --git a/api/utils/keys/piv_service.go b/api/utils/keys/piv_service.go new file mode 100644 index 00000000000..ae37e9f1e75 --- /dev/null +++ b/api/utils/keys/piv_service.go @@ -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 +} diff --git a/api/utils/keys/piv_service_fake.go b/api/utils/keys/piv_service_fake.go new file mode 100644 index 00000000000..d5281906315 --- /dev/null +++ b/api/utils/keys/piv_service_fake.go @@ -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 +} diff --git a/api/utils/keys/yubikey_test.go b/api/utils/keys/piv_service_test.go similarity index 54% rename from api/utils/keys/yubikey_test.go rename to api/utils/keys/piv_service_test.go index b06d8a3b350..2fe2fc30241 100644 --- a/api/utils/keys/yubikey_test.go +++ b/api/utils/keys/piv_service_test.go @@ -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() diff --git a/api/utils/keys/piv_service_unavailable.go b/api/utils/keys/piv_service_unavailable.go new file mode 100644 index 00000000000..52463d31e25 --- /dev/null +++ b/api/utils/keys/piv_service_unavailable.go @@ -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) +} diff --git a/api/utils/keys/policy.go b/api/utils/keys/policy.go index dc699170cd0..b625ca4f22f 100644 --- a/api/utils/keys/policy.go +++ b/api/utils/keys/policy.go @@ -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"` +} diff --git a/api/utils/keys/privatekey.go b/api/utils/keys/privatekey.go index 7e9167b6073..dcb66aa5780 100644 --- a/api/utils/keys/privatekey.go +++ b/api/utils/keys/privatekey.go @@ -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) } diff --git a/api/utils/keys/privatekey_test.go b/api/utils/keys/privatekey_test.go index 0133376073e..410c1093c94 100644 --- a/api/utils/keys/privatekey_test.go +++ b/api/utils/keys/privatekey_test.go @@ -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 ( diff --git a/api/utils/keys/yubikey.go b/api/utils/keys/yubikey.go index 811eeebc753..4bab4df2820 100644 --- a/api/utils/keys/yubikey.go +++ b/api/utils/keys/yubikey.go @@ -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()) } diff --git a/api/utils/keys/yubikey_common.go b/api/utils/keys/yubikey_common.go deleted file mode 100644 index d7c4616bb6a..00000000000 --- a/api/utils/keys/yubikey_common.go +++ /dev/null @@ -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 -} diff --git a/api/utils/keys/yubikey_fake.go b/api/utils/keys/yubikey_fake.go deleted file mode 100644 index f6599a3428f..00000000000 --- a/api/utils/keys/yubikey_fake.go +++ /dev/null @@ -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 -} diff --git a/api/utils/keys/yubikey_other.go b/api/utils/keys/yubikey_other.go deleted file mode 100644 index 61c38bb71f0..00000000000 --- a/api/utils/keys/yubikey_other.go +++ /dev/null @@ -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) -} diff --git a/lib/client/api.go b/lib/client/api.go index c7060ebf46a..e601b129042 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -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) } diff --git a/lib/teleterm/clusters/storage.go b/lib/teleterm/clusters/storage.go index 7d5b1292aa2..a546e081634 100644 --- a/lib/teleterm/clusters/storage.go +++ b/lib/teleterm/clusters/storage.go @@ -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 diff --git a/tool/tsh/common/hardware_key_test.go b/tool/tsh/common/hardware_key_test.go index 8b83f293b94..37cfd3d5f2d 100644 --- a/tool/tsh/common/hardware_key_test.go +++ b/tool/tsh/common/hardware_key_test.go @@ -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)