mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-01 21:52:02 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user