diff --git a/api/client/proxy/client.go b/api/client/proxy/client.go index f1d1b05540ec4..da6846822a5bc 100644 --- a/api/client/proxy/client.go +++ b/api/client/proxy/client.go @@ -38,6 +38,7 @@ import ( transportv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1" "github.com/gravitational/teleport/api/metadata" "github.com/gravitational/teleport/api/utils/grpc/interceptors" + "github.com/gravitational/teleport/api/utils/keys" ) // ClientConfig contains configuration needed for a Client @@ -124,7 +125,7 @@ func (c *ClientConfig) CheckAndSetDefaults(ctx context.Context) error { // before initiating the gRPC dial. // This approach works because the connection is cached for a few seconds, // allowing subsequent calls without requiring additional user action. - if priv, ok := cert.PrivateKey.(hardwareKeyWarmer); ok { + if priv, ok := cert.PrivateKey.(*keys.PrivateKey); ok { err := priv.WarmupHardwareKey(ctx) if err != nil { return nil, trace.Wrap(err) @@ -454,9 +455,3 @@ func (c *Client) Ping(ctx context.Context) error { _, _ = c.transport.ClusterDetails(ctx) return nil } - -// hardwareKeyWarmer performs a bogus call to the hardware key, -// to proactively prompt the user for a PIN/touch (if needed). -type hardwareKeyWarmer interface { - WarmupHardwareKey(ctx context.Context) error -} diff --git a/api/client/webclient/webclient.go b/api/client/webclient/webclient.go index 78d4c80c9aebc..a7869607735ab 100644 --- a/api/client/webclient/webclient.go +++ b/api/client/webclient/webclient.go @@ -45,6 +45,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" ) const ( @@ -528,7 +529,7 @@ type AuthenticationSettings struct { // PrivateKeyPolicy contains the cluster-wide private key policy. PrivateKeyPolicy keys.PrivateKeyPolicy `json:"private_key_policy"` // PIVSlot specifies a specific PIV slot to use with hardware key support. - PIVSlot keys.PIVSlot `json:"piv_slot"` + PIVSlot hardwarekey.PIVSlotKeyString `json:"piv_slot"` // DeviceTrust holds cluster-wide device trust settings. DeviceTrust DeviceTrustSettings `json:"device_trust,omitempty"` // HasMessageOfTheDay is a flag indicating that the cluster has MOTD diff --git a/api/profile/profile.go b/api/profile/profile.go index e652fe0153ac8..9b4efc7f8688a 100644 --- a/api/profile/profile.go +++ b/api/profile/profile.go @@ -35,6 +35,7 @@ import ( "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/sshutils" ) @@ -107,7 +108,7 @@ type Profile struct { PrivateKeyPolicy keys.PrivateKeyPolicy `yaml:"private_key_policy"` // PIVSlot is a specific piv slot that Teleport clients should use for hardware key support. - PIVSlot keys.PIVSlot `yaml:"piv_slot"` + PIVSlot hardwarekey.PIVSlotKeyString `yaml:"piv_slot"` // MissingClusterDetails means this profile was created with limited cluster details. // Missing cluster details should be loaded into the profile by pinging the proxy. diff --git a/api/testhelpers/mtls/mtls.go b/api/testhelpers/mtls/mtls.go index 07476fbf65897..127f6ca6887a5 100644 --- a/api/testhelpers/mtls/mtls.go +++ b/api/testhelpers/mtls/mtls.go @@ -56,7 +56,7 @@ func generateCA(t *testing.T) (*keys.PrivateKey, *x509.Certificate) { caPub, caPriv, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) - caKey, err := keys.NewPrivateKey(caPriv, nil) + caKey, err := keys.NewPrivateKey(caPriv) require.NoError(t, err) // Create a self signed certificate. @@ -97,7 +97,7 @@ func generateChildTLSConfigFromCA(t *testing.T, caKey *keys.PrivateKey, caCert * pub, priv, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) - key, err := keys.NewPrivateKey(priv, nil) + key, err := keys.NewPrivateKey(priv) require.NoError(t, err) // Create a certificate signed by the CA. diff --git a/api/types/authentication.go b/api/types/authentication.go index b5872f4651b51..3ac711f5e6ea2 100644 --- a/api/types/authentication.go +++ b/api/types/authentication.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/tlsutils" ) @@ -134,7 +135,7 @@ type AuthPreference interface { // GetHardwareKey returns the hardware key settings configured for the cluster. GetHardwareKey() (*HardwareKey, error) // GetPIVSlot returns the configured piv slot for the cluster. - GetPIVSlot() keys.PIVSlot + GetPIVSlot() hardwarekey.PIVSlotKeyString // GetHardwareKeySerialNumberValidation returns the cluster's hardware key // serial number validation settings. GetHardwareKeySerialNumberValidation() (*HardwareKeySerialNumberValidation, error) @@ -491,9 +492,9 @@ func (c *AuthPreferenceV2) GetHardwareKey() (*HardwareKey, error) { } // GetPIVSlot returns the configured piv slot for the cluster. -func (c *AuthPreferenceV2) GetPIVSlot() keys.PIVSlot { +func (c *AuthPreferenceV2) GetPIVSlot() hardwarekey.PIVSlotKeyString { if hk, err := c.GetHardwareKey(); err == nil { - return keys.PIVSlot(hk.PIVSlot) + return hardwarekey.PIVSlotKeyString(hk.PIVSlot) } return "" } @@ -840,7 +841,7 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error { } if hk, err := c.GetHardwareKey(); err == nil && hk.PIVSlot != "" { - if err := keys.PIVSlot(hk.PIVSlot).Validate(); err != nil { + if err := hardwarekey.PIVSlotKeyString(hk.PIVSlot).Validate(); err != nil { return trace.Wrap(err) } } diff --git a/api/utils/keys/hardwarekey/attestation.go b/api/utils/keys/hardwarekey/attestation.go new file mode 100644 index 0000000000000..fcf2ceaec5e0d --- /dev/null +++ b/api/utils/keys/hardwarekey/attestation.go @@ -0,0 +1,50 @@ +// 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 ( + "bytes" + + "github.com/gogo/protobuf/jsonpb" + "github.com/gravitational/trace" + + attestationv1 "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1" +) + +// AttestationStatement is an attestation statement for a hardware private key +// that supports json marshaling through the standard json/encoding package. +type AttestationStatement attestationv1.AttestationStatement + +// ToProto converts this AttestationStatement to its protobuf form. +func (ar *AttestationStatement) ToProto() *attestationv1.AttestationStatement { + return (*attestationv1.AttestationStatement)(ar) +} + +// AttestationStatementFromProto converts an AttestationStatement from its protobuf form. +func AttestationStatementFromProto(att *attestationv1.AttestationStatement) *AttestationStatement { + return (*AttestationStatement)(att) +} + +// MarshalJSON implements custom protobuf json marshaling. +func (ar *AttestationStatement) MarshalJSON() ([]byte, error) { + buf := new(bytes.Buffer) + err := (&jsonpb.Marshaler{}).Marshal(buf, ar.ToProto()) + return buf.Bytes(), trace.Wrap(err) +} + +// UnmarshalJSON implements custom protobuf json unmarshaling. +func (ar *AttestationStatement) UnmarshalJSON(buf []byte) error { + return jsonpb.Unmarshal(bytes.NewReader(buf), ar.ToProto()) +} diff --git a/api/utils/keys/cliprompt.go b/api/utils/keys/hardwarekey/cliprompt.go similarity index 77% rename from api/utils/keys/cliprompt.go rename to api/utils/keys/hardwarekey/cliprompt.go index 7dce20d211a7c..cb84089f5191b 100644 --- a/api/utils/keys/cliprompt.go +++ b/api/utils/keys/hardwarekey/cliprompt.go @@ -1,5 +1,3 @@ -//go:build piv && !pivtest - // Copyright 2024 Gravitational, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,22 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -package keys +package hardwarekey import ( "context" "fmt" "os" - "github.com/go-piv/piv-go/piv" "github.com/gravitational/trace" "github.com/gravitational/teleport/api/utils/prompt" ) -type cliPrompt struct{} +var ( + // defaultPIN for the PIV applet. The PIN is used to change the Management Key, + // and slots can optionally require it to perform signing operations. + defaultPIN = "123456" + // defaultPUK for the PIV applet. The PUK is only used to reset the PIN when + // the card's PIN retries have been exhausted. + defaultPUK = "12345678" +) + +type CLIPrompt struct{} -func (c *cliPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement) (string, error) { +func (c *CLIPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement, _ ContextualKeyInfo) (string, error) { message := "Enter your YubiKey PIV PIN" if requirement == PINOptional { message = "Enter your YubiKey PIV PIN [blank to use default PIN]" @@ -38,12 +44,12 @@ func (c *cliPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement return password, trace.Wrap(err) } -func (c *cliPrompt) Touch(_ context.Context) error { +func (c *CLIPrompt) Touch(_ context.Context, _ ContextualKeyInfo) error { _, err := fmt.Fprintln(os.Stderr, "Tap your YubiKey") return trace.Wrap(err) } -func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { +func (c *CLIPrompt) ChangePIN(ctx context.Context, _ ContextualKeyInfo) (*PINAndPUK, error) { var pinAndPUK = &PINAndPUK{} for { fmt.Fprintf(os.Stderr, "Please set a new 6-8 character PIN.\n") @@ -61,8 +67,8 @@ func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { continue } - if newPIN == piv.DefaultPIN { - fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) + if newPIN == defaultPIN { + fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", defaultPIN) continue } @@ -82,8 +88,8 @@ func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { pinAndPUK.PUK = puk switch puk { - case piv.DefaultPUK: - fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", piv.DefaultPUK) + case defaultPUK: + fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", defaultPUK) fallthrough case "": for { @@ -102,8 +108,8 @@ func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { continue } - if newPUK == piv.DefaultPUK { - fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", piv.DefaultPUK) + if newPUK == defaultPUK { + fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", defaultPUK) continue } @@ -120,11 +126,7 @@ func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { return pinAndPUK, nil } -func (c *cliPrompt) ConfirmSlotOverwrite(ctx context.Context, message string) (bool, error) { +func (c *CLIPrompt) ConfirmSlotOverwrite(ctx context.Context, message string, _ ContextualKeyInfo) (bool, error) { confirmation, err := prompt.Confirmation(ctx, os.Stderr, prompt.Stdin(), message) return confirmation, trace.Wrap(err) } - -func isPINLengthValid(pin string) bool { - return len(pin) >= 6 && len(pin) <= 8 -} diff --git a/api/utils/keys/hardwarekey/hardwarekey.go b/api/utils/keys/hardwarekey/hardwarekey.go new file mode 100644 index 0000000000000..44525534ab1f9 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey.go @@ -0,0 +1,226 @@ +// 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" + "crypto/sha512" + "crypto/x509" + "encoding/json" + "io" + + "github.com/gravitational/trace" +) + +// Service for interfacing with hardware private keys. +type Service interface { + // NewPrivateKey creates or retrieves a hardware private key for the given config. + NewPrivateKey(ctx context.Context, config PrivateKeyConfig) (*PrivateKey, error) + // Sign performs a cryptographic signature using the specified hardware + // private key and provided signature parameters. + Sign(ctx context.Context, ref *PrivateKeyRef, keyInfo ContextualKeyInfo, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) + // SetPrompt sets the hardware key prompt used by the hardware key service, if applicable. + // This is used by Teleport Connect which sets the prompt later than the hardware key service, + // due to process initialization constraints. + SetPrompt(prompt Prompt) +} + +// PrivateKey is a hardware private key implementation of [crypto.Signer]. +type PrivateKey struct { + service Service + ref *PrivateKeyRef + keyInfo ContextualKeyInfo +} + +// NewPrivateKey returns a [PrivateKey] for the given service and ref. +// keyInfo is an optional argument to supply additional contextual info. +func NewPrivateKey(s Service, ref *PrivateKeyRef, keyInfo ContextualKeyInfo) *PrivateKey { + return &PrivateKey{ + service: s, + ref: ref, + keyInfo: keyInfo, + } +} + +// Encode the hardware private key ref in a format understood by other Teleport clients. +func (p *PrivateKey) Encode() ([]byte, error) { + return p.ref.encode() +} + +// DecodePrivateKey decodes an encoded hardware private key for the given service. +// +// The updateKeyRef func is provided to fill in missing details for old client logins. +// TODO(Joerger): DELETE IN v19.0.0 +func DecodePrivateKey(s Service, encodedKey []byte, keyInfo ContextualKeyInfo, updateKeyRef func(ref *PrivateKeyRef) error) (*PrivateKey, error) { + ref, err := decodeKeyRef(encodedKey) + if err != nil { + return nil, trace.Wrap(err) + } + + // If the public key is missing, this is likely an old login key with only + // the serial number and slot. Fetch missing data from the hardware key. + // This data will be saved to the login key on next login + // TODO(Joerger): DELETE IN v19.0.0 + if ref.PublicKey == nil { + if err := updateKeyRef(ref); err != nil { + return nil, trace.Wrap(err) + } + } + + return NewPrivateKey(s, ref, keyInfo), nil +} + +// Public implements [crypto.Signer]. +func (h *PrivateKey) Public() crypto.PublicKey { + return h.ref.PublicKey +} + +// Sign implements [crypto.Signer]. +func (h *PrivateKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + // When context.TODO() is passed, the service should replace this with its own parent context. + return h.service.Sign(context.TODO(), h.ref, h.keyInfo, rand, digest, opts) +} + +// GetAttestation returns the hardware private key attestation details. +func (h *PrivateKey) GetAttestationStatement() *AttestationStatement { + return h.ref.AttestationStatement +} + +// GetPrivateKeyPolicy returns the PrivateKeyPolicy satisfied by this key. +func (h *PrivateKey) GetPromptPolicy() PromptPolicy { + return h.ref.Policy +} + +// WarmupHardwareKey performs a bogus sign() call to prompt the user for PIN/touch (if needed). +func (h *PrivateKey) 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 fake "pivtest" service, but we may extend support in + // the future as newer keys are being made with ed25519 support (YubiKey 5.7.x, SoloKey). + hash := sha512.Sum512(make([]byte, 512)) + _, err := h.service.Sign(ctx, h.ref, h.keyInfo, rand.Reader, hash[:], crypto.SHA512) + return trace.Wrap(err, "failed to perform warmup signature with hardware private key") +} + +// 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:"-"` // uses custom JSON marshaling in PKIX, ASN.1 DER form + // Policy specifies the hardware private key's PIN/touch prompt policies. + Policy PromptPolicy `json:"policy"` + // 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:"attestation_statement"` +} + +// 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 +} + +// These types are used for custom marshaling of the crypto.PublicKey field in [PrivateKeyRef]. +type refAlias PrivateKeyRef +type hardwarePrivateKeyRefJSON struct { + // embedding an alias type instead of [HardwarePrivateKeyRef] prevents the custom marshaling + // from recursively applying, which would result in a stack overflow. + refAlias + PublicKeyDER []byte `json:"public_key"` +} + +// UnmarshalJSON marshals [PrivateKeyRef] with custom logic for the public key. +func (r PrivateKeyRef) MarshalJSON() ([]byte, error) { + var pubDER []byte + if r.PublicKey != nil { + var err error + if pubDER, err = x509.MarshalPKIXPublicKey(r.PublicKey); err != nil { + return nil, trace.Wrap(err) + } + } + + return json.Marshal(&hardwarePrivateKeyRefJSON{ + refAlias: refAlias(r), + PublicKeyDER: pubDER, + }) +} + +// UnmarshalJSON unmarshals [PrivateKeyRef] with custom logic for the public key. +func (r *PrivateKeyRef) UnmarshalJSON(b []byte) error { + ref := hardwarePrivateKeyRefJSON{} + err := json.Unmarshal(b, &ref) + if err != nil { + return trace.Wrap(err) + } + + if len(ref.PublicKeyDER) > 0 { + ref.refAlias.PublicKey, err = x509.ParsePKIXPublicKey(ref.PublicKeyDER) + if err != nil { + return trace.Wrap(err) + } + } + + *r = PrivateKeyRef(ref.refAlias) + return 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 + // ContextualKeyInfo contains additional info to associate with the key. + ContextualKeyInfo ContextualKeyInfo +} + +// ContextualKeyInfo contains contextual information associated with a hardware [PrivateKey]. +// TODO(Joerger): This is not hardware key specific, so it may be better placed in a more general package +// if it is used more broadly, though moving this to the keys package would cause an import cycle. +type ContextualKeyInfo struct { + // ProxyHost is the root proxy hostname that the key is associated with. + ProxyHost string + // Username is a Teleport username that the key is associated with. + Username string + // ClusterName is a Teleport cluster name that the key is associated with. + ClusterName string +} diff --git a/api/utils/keys/hardwarekey/hardwarekey_test.go b/api/utils/keys/hardwarekey/hardwarekey_test.go new file mode 100644 index 0000000000000..81ca04c03f769 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey_test.go @@ -0,0 +1,123 @@ +// 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/ed25519" + "io" + "testing" + + "github.com/stretchr/testify/require" + + attestationv1 "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" +) + +// TestEncodeDecodePrivateKey tests encoding and decoding a hardware private key. +// 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 TestEncodeDecodePrivateKey(t *testing.T) { + s := &mockHardwareKeyService{} + + pub, _, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + fullRef := &hardwarekey.PrivateKeyRef{ + SerialNumber: 12345678, + SlotKey: hardwarekey.PivSlotKeyTouch, + PublicKey: pub, + Policy: hardwarekey.PromptPolicyTouch, + AttestationStatement: &hardwarekey.AttestationStatement{ + AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{ + YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{ + SlotCert: []byte{1}, + AttestationCert: []byte{2}, + }, + }, + }, + } + + contextualKeyInfo := hardwarekey.ContextualKeyInfo{ + ProxyHost: "billy.io", + Username: "Billy@billy.io", + ClusterName: "billy.io", + } + priv := hardwarekey.NewPrivateKey(s, fullRef, contextualKeyInfo) + + for _, tt := range []struct { + name string + ref *hardwarekey.PrivateKeyRef + updateKeyRef func(*hardwarekey.PrivateKeyRef) error + expectPriv *hardwarekey.PrivateKey + }{ + { + name: "new client encoding", + ref: fullRef, + expectPriv: priv, + }, + { + // Old client logins would only have encoded the serial number and slot key. + // TODO(Joerger): DELETE IN v19.0.0 + name: "old client encoding", + ref: &hardwarekey.PrivateKeyRef{ + SerialNumber: 12345678, + SlotKey: hardwarekey.PivSlotKeyTouch, + }, + updateKeyRef: func(ref *hardwarekey.PrivateKeyRef) error { + ref.PublicKey = pub + ref.Policy = hardwarekey.PromptPolicyTouch + ref.AttestationStatement = &hardwarekey.AttestationStatement{ + AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{ + YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{ + SlotCert: []byte{1}, + AttestationCert: []byte{2}, + }, + }, + } + return nil + }, + expectPriv: priv, + }, + } { + t.Run(tt.name, func(t *testing.T) { + priv := hardwarekey.NewPrivateKey(s, tt.ref, hardwarekey.ContextualKeyInfo{}) + encoded, err := priv.Encode() + require.NoError(t, err) + + decodedPriv, err := hardwarekey.DecodePrivateKey(s, encoded, contextualKeyInfo, tt.updateKeyRef) + require.NoError(t, err) + require.Equal(t, tt.expectPriv, decodedPriv) + }) + } + +} + +type mockHardwareKeyService struct{} + +func (s *mockHardwareKeyService) NewPrivateKey(_ context.Context, _ hardwarekey.PrivateKeyConfig) (*hardwarekey.PrivateKey, error) { + return nil, nil +} + +// Sign performs a cryptographic signature using the specified hardware +// private key and provided signature parameters. +func (s *mockHardwareKeyService) Sign(_ context.Context, _ *hardwarekey.PrivateKeyRef, _ hardwarekey.ContextualKeyInfo, _ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) { + return nil, nil +} + +func (s *mockHardwareKeyService) SetPrompt(_ hardwarekey.Prompt) {} diff --git a/api/utils/keys/hardwarekey/prompt.go b/api/utils/keys/hardwarekey/prompt.go new file mode 100644 index 0000000000000..8519fe338fd17 --- /dev/null +++ b/api/utils/keys/hardwarekey/prompt.go @@ -0,0 +1,96 @@ +// 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" + + "github.com/gravitational/trace" +) + +var ( + PromptPolicyNone = PromptPolicy{TouchRequired: false, PINRequired: false} + PromptPolicyTouch = PromptPolicy{TouchRequired: true, PINRequired: false} + PromptPolicyPIN = PromptPolicy{TouchRequired: false, PINRequired: true} + PromptPolicyTouchAndPIN = PromptPolicy{TouchRequired: true, PINRequired: true} +) + +// PromptPolicy specifies a hardware private key's PIN/touch prompt policies. +type PromptPolicy struct { + // TouchRequired means that touch is required for signatures. + TouchRequired bool + // PINRequired means that PIN is required for signatures. + PINRequired bool +} + +// Prompt provides methods to interact with a hardware [PrivateKey]. +type Prompt interface { + // AskPIN prompts the user for a PIN. + // The requirement tells if the PIN is required or optional. + AskPIN(ctx context.Context, requirement PINPromptRequirement, keyInfo ContextualKeyInfo) (string, error) + // Touch prompts the user to touch the hardware key. + Touch(ctx context.Context, keyInfo ContextualKeyInfo) error + // ChangePIN asks for a new PIN. + // If the PUK has a default value, it should ask for the new value for it. + // It is up to the implementer how the validation is handled. + // For example, CLI prompt can ask for a valid PIN/PUK in a loop, a GUI + // prompt can use the frontend validation. + ChangePIN(ctx context.Context, keyInfo ContextualKeyInfo) (*PINAndPUK, error) + // ConfirmSlotOverwrite asks the user if the slot's private key and certificate can be overridden. + ConfirmSlotOverwrite(ctx context.Context, message string, keyInfo ContextualKeyInfo) (bool, error) +} + +// PINPromptRequirement specifies whether a PIN is required. +type PINPromptRequirement int + +const ( + // PINOptional allows the user to proceed without entering a PIN. + PINOptional PINPromptRequirement = iota + // PINRequired enforces that a PIN must be entered to proceed. + PINRequired +) + +// PINAndPUK describes a response returned from [Prompt].ChangePIN. +type PINAndPUK struct { + // New PIN set by the user. + PIN string + // PUK used to change the PIN. + // This is a new PUK if it has not been changed (from the default PUK). + PUK string + // PUKChanged is true if the user changed the default PUK. + PUKChanged bool +} + +// Validate the user-provided PIN and PUK. +func (p PINAndPUK) Validate() error { + if !isPINLengthValid(p.PIN) { + return trace.BadParameter("PIN must be 6-8 characters long") + } + if p.PIN == defaultPIN { + return trace.BadParameter("The default PIN is not supported") + } + if !isPINLengthValid(p.PUK) { + return trace.BadParameter("PUK must be 6-8 characters long") + } + if p.PUK == defaultPUK { + return trace.BadParameter("The default PUK is not supported") + } + return nil +} + +// isPINLengthValid returns whether the given PIV PIN, or PUK, is of valid length (6-8 characters). +func isPINLengthValid(pin string) bool { + return len(pin) >= 6 && len(pin) <= 8 +} diff --git a/api/utils/keys/hardwarekey/slot.go b/api/utils/keys/hardwarekey/slot.go new file mode 100644 index 0000000000000..092177bd27e7d --- /dev/null +++ b/api/utils/keys/hardwarekey/slot.go @@ -0,0 +1,74 @@ +// 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 ( + "strconv" + + "github.com/gravitational/trace" +) + +// 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 + +const ( + pivSlotKeyBasic PIVSlotKey = 0x9a + PivSlotKeyTouch PIVSlotKey = 0x9c + pivSlotKeyTouchAndPIN PIVSlotKey = 0x9d + pivSlotKeyPIN PIVSlotKey = 0x9e +) + +// GetDefaultSlotKey gets the default PIV slot key for the given [policy]. +func GetDefaultSlotKey(policy PromptPolicy) (PIVSlotKey, error) { + switch policy { + case PromptPolicyNone: + return pivSlotKeyBasic, nil + case PromptPolicyTouch: + return PivSlotKeyTouch, nil + case PromptPolicyPIN: + return pivSlotKeyPIN, nil + case PromptPolicyTouchAndPIN: + return pivSlotKeyTouchAndPIN, nil + default: + return 0, trace.BadParameter("unexpected prompt policy %v", policy) + } +} + +// PIVSlotKeyString is the string representation of a [PIVSlotKey]. +type PIVSlotKeyString string + +// Validate that [s] parses into a valid [PIVSlotKey]. +func (s PIVSlotKeyString) Validate() error { + _, err := s.Parse() + return trace.Wrap(err) +} + +// Parse [s] into a [PIVSlotKey]. +func (s PIVSlotKeyString) Parse() (PIVSlotKey, error) { + slotKey, err := strconv.ParseUint(string(s), 16, 32) + if err != nil { + return 0, trace.Wrap(err, "failed to parse %q as a uint", s) + } + + switch p := PIVSlotKey(slotKey); p { + case pivSlotKeyBasic, PivSlotKeyTouch, pivSlotKeyTouchAndPIN, pivSlotKeyPIN: + return p, nil + default: + return 0, trace.BadParameter("invalid PIV slot %q", s) + } +} diff --git a/api/utils/keys/hardwarekey/slot_test.go b/api/utils/keys/hardwarekey/slot_test.go new file mode 100644 index 0000000000000..51ede5346b899 --- /dev/null +++ b/api/utils/keys/hardwarekey/slot_test.go @@ -0,0 +1,68 @@ +// 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 ( + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" +) + +func TestXxx(t *testing.T) { + for _, tt := range []struct { + slotString hardwarekey.PIVSlotKeyString + expectPIVSlotKey hardwarekey.PIVSlotKey + assertError require.ErrorAssertionFunc + }{ + { + slotString: "9a", + expectPIVSlotKey: 0x9a, + assertError: require.NoError, + }, { + slotString: "9c", + expectPIVSlotKey: 0x9c, + assertError: require.NoError, + }, { + slotString: "9d", + expectPIVSlotKey: 0x9d, + assertError: require.NoError, + }, { + slotString: "9e", + expectPIVSlotKey: 0x9e, + assertError: require.NoError, + }, { + slotString: "invalid_uint", + expectPIVSlotKey: 0, + assertError: require.Error, + }, { + slotString: "9b", // unsupported slot key + expectPIVSlotKey: 0, + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + }, + }, + } { + t.Run(string(tt.slotString), func(t *testing.T) { + pivSlotKey, err := tt.slotString.Parse() + tt.assertError(t, err) + require.Equal(t, tt.expectPIVSlotKey, pivSlotKey) + }) + } +} diff --git a/api/utils/keys/hardwaresigner.go b/api/utils/keys/hardwaresigner.go deleted file mode 100644 index d89b3053b9f4a..0000000000000 --- a/api/utils/keys/hardwaresigner.go +++ /dev/null @@ -1,90 +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 ( - "bytes" - "crypto" - - "github.com/gogo/protobuf/jsonpb" - "github.com/gravitational/trace" - - attestation "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1" -) - -// 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() *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() *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 -} - -// AttestationStatement is an attestation statement for a hardware private key -// that supports json marshaling through the standard json/encoding package. -type AttestationStatement attestation.AttestationStatement - -// ToProto converts this AttestationStatement to its protobuf form. -func (ar *AttestationStatement) ToProto() *attestation.AttestationStatement { - return (*attestation.AttestationStatement)(ar) -} - -// AttestationStatementFromProto converts an AttestationStatement from its protobuf form. -func AttestationStatementFromProto(att *attestation.AttestationStatement) *AttestationStatement { - return (*AttestationStatement)(att) -} - -// MarshalJSON implements custom protobuf json marshaling. -func (ar *AttestationStatement) MarshalJSON() ([]byte, error) { - buf := new(bytes.Buffer) - err := (&jsonpb.Marshaler{}).Marshal(buf, ar.ToProto()) - return buf.Bytes(), trace.Wrap(err) -} - -// UnmarshalJSON implements custom protobuf json unmarshaling. -func (ar *AttestationStatement) UnmarshalJSON(buf []byte) error { - return jsonpb.Unmarshal(bytes.NewReader(buf), ar.ToProto()) -} - -// 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 48bb77f9f2a3d..0000000000000 --- 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/yubikey.go b/api/utils/keys/piv/yubikey.go similarity index 52% rename from api/utils/keys/yubikey.go rename to api/utils/keys/piv/yubikey.go index 1c1fe1aea5a82..bfe8f6b85bb74 100644 --- a/api/utils/keys/yubikey.go +++ b/api/utils/keys/piv/yubikey.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package keys +package piv import ( "context" @@ -21,18 +21,10 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/sha256" "crypto/x509" "crypto/x509/pkix" - "encoding/hex" - "encoding/json" - "encoding/pem" - "errors" - "fmt" "io" "math/big" - "os" - "strconv" "strings" "sync" "time" @@ -41,268 +33,92 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport/api" - attestation "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1" + attestationv1 "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/retryutils" ) -const ( - // PIVCardTypeYubiKey is the PIV card type assigned to yubiKeys. - PIVCardTypeYubiKey = "yubikey" -) - -// Cache keys to prevent reconnecting to PIV module to discover a known key. -// -// Additionally, this allows the program to cache the key's PIN (if applicable) -// after the user is prompted the first time, preventing redundant prompts when -// the key is retrieved multiple times. -// -// Note: in most cases the connection caches the PIN itself, and connections can be -// reclaimed before they are fully closed (within a few seconds). However, in uncommon -// 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{} - 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 PIVSlot, prompt HardwareKeyPrompt) (*PrivateKey, error) { - if prompt == nil { - prompt = &cliPrompt{} - } - cachedKeysMu.Lock() - defer cachedKeysMu.Unlock() - - // Get the default PIV slot or the piv slot requested. - pivSlot, err := GetDefaultKeySlot(requiredKeyPolicy) - if err != nil { - return nil, trace.Wrap(err) - } - if slot != "" { - pivSlot, err = slot.parse() - 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 && key.GetPrivateKeyPolicy() == requiredKeyPolicy { - return key, nil - } +// YubiKey is a specific YubiKey PIV card. +type YubiKey struct { + // conn is a shared YubiKey PIV connection. + // + // PIV connections claim an exclusive lock on the PIV module until closed. + // In order to improve connection sharing for this program without locking + // out other programs during extended program executions (like "tsh proxy ssh"), + // this connections is opportunistically formed and released after being + // unused for a few seconds. + c *sharedPIVConnection + // serialNumber is the YubiKey's 8 digit serial number. + serialNumber uint32 + // version is the YubiKey's version. + version piv.Version +} - // Use the first yubiKey we find. - y, err := FindYubiKey(0, prompt) +// FindYubiKey finds a YubiKey PIV card by serial number. If no serial +// number is provided, the first YubiKey found will be returned. +func FindYubiKey(serialNumber uint32) (*YubiKey, error) { + yubiKeyCards, err := findYubiKeyCards() if err != nil { return nil, trace.Wrap(err) } - // If PIN is required, check that PIN and PUK are not the defaults. - if requiredKeyPolicy.isHardwareKeyPINVerified() { - if err := y.checkOrSetPIN(ctx); err != nil { - return nil, trace.Wrap(err) - } - } - - promptOverwriteSlot := func(msg string) error { - promptQuestion := fmt.Sprintf("%v\nWould you like to overwrite this slot's private key and certificate?", msg) - if confirmed, confirmErr := prompt.ConfirmSlotOverwrite(ctx, promptQuestion); confirmErr != nil { - return trace.Wrap(confirmErr) - } else if !confirmed { - return trace.Wrap(trace.CompareFailed(msg), "user declined to overwrite slot") + if len(yubiKeyCards) == 0 { + if serialNumber != 0 { + return nil, trace.ConnectionProblem(nil, "no YubiKey device connected with serial number %d", serialNumber) } - return nil + return nil, trace.ConnectionProblem(nil, "no YubiKey device connected") } - // If a custom slot was not specified, check for a key in the - // default slot for the given policy and generate a new one if needed. - if slot == "" { - pivSlot, err = GetDefaultKeySlot(requiredKeyPolicy) + for _, card := range yubiKeyCards { + y, err := newYubiKey(card) if err != nil { return nil, trace.Wrap(err) } - // Check the client certificate in the slot. - switch cert, err := y.getCertificate(pivSlot); { - case err == nil && (len(cert.Subject.Organization) == 0 || cert.Subject.Organization[0] != certOrgName): - // Unknown cert found, prompt the user before we overwrite the slot. - if err := promptOverwriteSlot(nonTeleportCertificateMessage(pivSlot, cert)); err != nil { - return nil, trace.Wrap(err) - } - - // user confirmed, generate a new key. - fallthrough - case errors.Is(err, piv.ErrNotFound): - // no cert found, generate a new key. - priv, err := y.generatePrivateKeyAndCert(pivSlot, requiredKeyPolicy) - return priv, trace.Wrap(err) - case err != nil: - return nil, trace.Wrap(err) - } - } - - // Get the key in the slot, or generate a new one if needed. - priv, err := y.getPrivateKey(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. - msg := fmt.Sprintf("private key in YubiKey PIV slot %q does not meet private key policy %q.", pivSlot, requiredKeyPolicy) - if err := promptOverwriteSlot(msg); err != nil { - return nil, trace.Wrap(err) + if serialNumber == 0 || y.serialNumber == serialNumber { + return y, nil } - - // user confirmed, generate a new key. - fallthrough - case trace.IsNotFound(err): - // no key found, generate a new key. - priv, err = y.generatePrivateKeyAndCert(pivSlot, requiredKeyPolicy) - return priv, trace.Wrap(err) - case err != nil: - return nil, trace.Wrap(err) } - return priv, nil -} - -func GetDefaultKeySlot(policy PrivateKeyPolicy) (piv.Slot, error) { - switch policy { - case PrivateKeyPolicyHardwareKey: - // private_key_policy: hardware_key -> 9a - return piv.SlotAuthentication, nil - case PrivateKeyPolicyHardwareKeyTouch: - // private_key_policy: hardware_key_touch -> 9c - return piv.SlotSignature, nil - case PrivateKeyPolicyHardwareKeyTouchAndPIN: - // private_key_policy: hardware_key_touch_and_pin -> 9d - return piv.SlotKeyManagement, nil - case PrivateKeyPolicyHardwareKeyPIN: - // private_key_policy: hardware_key_pin -> 9e - return piv.SlotCardAuthentication, nil - default: - return piv.Slot{}, trace.BadParameter("unexpected private key policy %v", policy) - } -} - -func getKeyPolicies(policy PrivateKeyPolicy) (piv.TouchPolicy, piv.PINPolicy, error) { - switch policy { - case PrivateKeyPolicyHardwareKey: - return piv.TouchPolicyNever, piv.PINPolicyNever, nil - case PrivateKeyPolicyHardwareKeyTouch: - return piv.TouchPolicyCached, piv.PINPolicyNever, nil - case PrivateKeyPolicyHardwareKeyPIN: - return piv.TouchPolicyNever, piv.PINPolicyOnce, nil - case PrivateKeyPolicyHardwareKeyTouchAndPIN: - return piv.TouchPolicyCached, piv.PINPolicyOnce, nil - default: - return piv.TouchPolicyNever, piv.PINPolicyNever, trace.BadParameter("unexpected private key policy %v", policy) - } -} - -func nonTeleportCertificateMessage(slot piv.Slot, cert *x509.Certificate) string { - // Gather a small list of user-readable x509 certificate fields to display to the user. - sum := sha256.Sum256(cert.Raw) - fingerPrint := hex.EncodeToString(sum[:]) - return fmt.Sprintf(`Certificate in YubiKey PIV slot %q is not a Teleport client cert: -Slot %s: - Algorithm: %v - Subject DN: %v - Issuer DN: %v - Serial: %v - Fingerprint: %v - Not before: %v - Not after: %v -`, - slot, slot, - cert.SignatureAlgorithm, - cert.Subject, - cert.Issuer, - cert.SerialNumber, - fingerPrint, - cert.NotBefore, - cert.NotAfter, - ) -} - -// YubiKeyPrivateKey is a YubiKey PIV private key. Cryptographical operations open -// a new temporary connection to the PIV card to perform the operation. -type YubiKeyPrivateKey struct { - // YubiKey is a specific YubiKey PIV module. - *YubiKey - - pivSlot piv.Slot - signMux sync.Mutex - - slotCert *x509.Certificate - attestationCert *x509.Certificate - 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 uint32 `json:"slot_key"` + return nil, trace.ConnectionProblem(nil, "no YubiKey device connected with serial number %d", serialNumber) } -func parseYubiKeyPrivateKeyData(keyDataBytes []byte, prompt HardwareKeyPrompt) (*PrivateKey, error) { - if prompt == nil { - prompt = &cliPrompt{} - } - cachedKeysMu.Lock() - defer cachedKeysMu.Unlock() +// pivCardTypeYubiKey is the PIV card type assigned to yubiKeys. +const pivCardTypeYubiKey = "yubikey" - var keyData yubiKeyPrivateKeyData - if err := json.Unmarshal(keyDataBytes, &keyData); err != nil { - return nil, trace.Wrap(err) - } - - pivSlot, err := parsePIVSlot(keyData.SlotKey) +// findYubiKeyCards returns a list of connected yubiKey PIV card names. +func findYubiKeyCards() ([]string, error) { + cards, err := piv.Cards() 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 + var yubiKeyCards []string + for _, card := range cards { + if strings.Contains(strings.ToLower(card), pivCardTypeYubiKey) { + yubiKeyCards = append(yubiKeyCards, card) + } } - y, err := FindYubiKey(keyData.SerialNumber, prompt) - if err != nil { - return nil, trace.Wrap(err) + return yubiKeyCards, nil +} + +func newYubiKey(card string) (*YubiKey, error) { + y := &YubiKey{ + c: &sharedPIVConnection{ + card: card, + }, } - priv, err := y.getPrivateKey(pivSlot) - if err != nil { + var err error + if y.serialNumber, err = y.c.getSerialNumber(); 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 -} - -// WarmupHardwareKey performs a bogus sign() call to prompt the user for -// a PIN/touch (if needed). -func (y *YubiKeyPrivateKey) WarmupHardwareKey(ctx context.Context) error { - hash := sha256.Sum256(make([]byte, 256)) - _, err := y.sign(ctx, rand.Reader, hash[:], crypto.SHA256) - return trace.Wrap(err, "failed to access a YubiKey private key") -} - -// Sign implements crypto.Signer. -func (y *YubiKeyPrivateKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - signature, err := y.sign(ctx, rand, digest, opts) - if err != nil { + if y.version, err = y.c.getVersion(); err != nil { return nil, trace.Wrap(err) } - return signature, nil + return y, nil } // YubiKeys require touch when signing with a private key that requires touch. @@ -322,25 +138,22 @@ const ( signTouchPromptDelay = time.Millisecond * 200 ) -func (y *YubiKeyPrivateKey) sign(ctx context.Context, rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { - // To prevent concurrent calls to sign from failing due to PIV only handling a - // single connection, use a lock to queue through signature requests one at a time. - y.signMux.Lock() - defer y.signMux.Unlock() +func (y *YubiKey) sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, keyInfo hardwarekey.ContextualKeyInfo, prompt hardwarekey.Prompt, rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) // Lock the connection for the entire duration of the sign // process. Without this, the connection will be released, // leading to a failure when providing PIN or touch input: // "verify pin: transmitting request: the supplied handle was invalid". - release, err := y.connect() + release, err := y.c.connect() if err != nil { return nil, trace.Wrap(err) } defer release() var touchPromptDelayTimer *time.Timer - if y.attestation.TouchPolicy != piv.TouchPolicyNever { + if ref.Policy.TouchRequired { touchPromptDelayTimer = time.NewTimer(signTouchPromptDelay) defer touchPromptDelayTimer.Stop() @@ -348,7 +161,7 @@ func (y *YubiKeyPrivateKey) sign(ctx context.Context, rand io.Reader, digest []b select { case <-touchPromptDelayTimer.C: // Prompt for touch after a delay, in case the function succeeds without touch due to a cached touch. - err := y.prompt.Touch(ctx) + err := prompt.Touch(ctx, keyInfo) if err != nil { // Cancel the entire function when an error occurs. // This is typically used for aborting the prompt. @@ -370,13 +183,18 @@ func (y *YubiKeyPrivateKey) sign(ctx context.Context, rand io.Reader, digest []b defer touchPromptDelayTimer.Reset(signTouchPromptDelay) } } - pass, err := y.prompt.AskPIN(ctx, PINRequired) + pass, err := prompt.AskPIN(ctx, hardwarekey.PINRequired, keyInfo) return pass, trace.Wrap(err) } + pinPolicy := piv.PINPolicyNever + if ref.Policy.PINRequired { + pinPolicy = piv.PINPolicyOnce + } + auth := piv.KeyAuth{ PINPrompt: promptPIN, - PINPolicy: y.attestation.PINPolicy, + PINPolicy: pinPolicy, } // YubiKeys with firmware version 5.3.1 have a bug where insVerify(0x20, 0x00, 0x80, nil) @@ -386,14 +204,19 @@ func (y *YubiKeyPrivateKey) sign(ctx context.Context, rand io.Reader, digest []b // the signature fails. manualRetryWithPIN := false fw531 := piv.Version{Major: 5, Minor: 3, Patch: 1} - if auth.PINPolicy == piv.PINPolicyOnce && y.attestation.Version == fw531 { + if auth.PINPolicy == piv.PINPolicyOnce && y.c.conn.Version() == fw531 { // Set the keys PIN policy to never to skip the insVerify check. If PIN was provided in // a previous recent call, the signature will succeed as expected of the "once" policy. auth.PINPolicy = piv.PINPolicyNever manualRetryWithPIN = true } - privateKey, err := y.privateKey(y.pivSlot, y.Public(), auth) + pivSlot, err := parsePIVSlot(ref.SlotKey) + if err != nil { + return nil, trace.Wrap(err) + } + + privateKey, err := y.c.privateKey(pivSlot, ref.PublicKey, auth) if err != nil { return nil, trace.Wrap(err) } @@ -416,7 +239,7 @@ func (y *YubiKeyPrivateKey) sign(ctx context.Context, rand io.Reader, digest []b if err != nil { return nil, trace.Wrap(err) } - if err := y.verifyPIN(pin); err != nil { + if err := y.c.verifyPIN(pin); err != nil { return nil, trace.Wrap(err) } signature, err := abandonableSign(ctx, signer, rand, digest, opts) @@ -458,109 +281,42 @@ 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) +// Reset resets the YubiKey PIV module to default settings. +func (y *YubiKey) Reset() error { + err := y.c.reset() + return trace.Wrap(err) } -func (y *YubiKeyPrivateKey) keyPEM() ([]byte, error) { - keyDataBytes, err := json.Marshal(yubiKeyPrivateKeyData{ - SerialNumber: y.serialNumber, - SlotKey: y.pivSlot.Key, - }) - if err != nil { - return nil, trace.Wrap(err) +// generatePrivateKey generates a new private key in the given PIV slot. +func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPolicy) (*hardwarekey.PrivateKeyRef, error) { + touchPolicy := piv.TouchPolicyNever + if policy.TouchRequired { + touchPolicy = piv.TouchPolicyCached } - return pem.EncodeToMemory(&pem.Block{ - Type: pivYubiKeyPrivateKeyType, - Headers: nil, - Bytes: keyDataBytes, - }), nil -} - -// GetAttestationStatement returns an AttestationStatement for this YubiKeyPrivateKey. -func (y *YubiKeyPrivateKey) GetAttestationStatement() *AttestationStatement { - return &AttestationStatement{ - AttestationStatement: &attestation.AttestationStatement_YubikeyAttestationStatement{ - YubikeyAttestationStatement: &attestation.YubiKeyAttestationStatement{ - SlotCert: y.slotCert.Raw, - AttestationCert: y.attestationCert.Raw, - }, - }, + pinPolicy := piv.PINPolicyNever + if policy.PINRequired { + pinPolicy = piv.PINPolicyOnce } -} -// GetPrivateKeyPolicy returns the PrivateKeyPolicy supported by this YubiKeyPrivateKey. -func (y *YubiKeyPrivateKey) GetPrivateKeyPolicy() PrivateKeyPolicy { - return GetPrivateKeyPolicyFromAttestation(y.attestation) -} - -// GetPrivateKeyPolicyFromAttestation returns the PrivateKeyPolicy satisfied by the given hardware key attestation. -func GetPrivateKeyPolicyFromAttestation(att *piv.Attestation) PrivateKeyPolicy { - isTouchPolicy := att.TouchPolicy == piv.TouchPolicyCached || - att.TouchPolicy == piv.TouchPolicyAlways - - isPINPolicy := att.PINPolicy == piv.PINPolicyOnce || - att.PINPolicy == piv.PINPolicyAlways - - switch { - case isPINPolicy && isTouchPolicy: - return PrivateKeyPolicyHardwareKeyTouchAndPIN - case isPINPolicy: - return PrivateKeyPolicyHardwareKeyPIN - case isTouchPolicy: - return PrivateKeyPolicyHardwareKeyTouch - default: - return PrivateKeyPolicyHardwareKey + opts := piv.Key{ + Algorithm: piv.AlgorithmEC256, + PINPolicy: pinPolicy, + TouchPolicy: touchPolicy, } -} - -// YubiKey is a specific YubiKey PIV card. -type YubiKey struct { - // conn is a shared YubiKey PIV connection. - // - // PIV connections claim an exclusive lock on the PIV module until closed. - // In order to improve connection sharing for this program without locking - // out other programs during extended program executions (like "tsh proxy ssh"), - // this connections is opportunistically formed and released after being - // unused for a few seconds. - *sharedPIVConnection - // serialNumber is the yubiKey's 8 digit serial number. - serialNumber uint32 - prompt HardwareKeyPrompt -} -func newYubiKey(card string, prompt HardwareKeyPrompt) (*YubiKey, error) { - y := &YubiKey{ - sharedPIVConnection: &sharedPIVConnection{ - card: card, - }, - prompt: prompt, + pub, err := y.c.generateKey(piv.DefaultManagementKey, slot, opts) + if err != nil { + return nil, trace.Wrap(err) } - serialNumber, err := y.serial() + slotCert, err := y.c.attest(slot) if err != nil { return nil, trace.Wrap(err) } - y.serialNumber = serialNumber - return y, nil -} - -// Reset resets the YubiKey PIV module to default settings. -func (y *YubiKey) Reset() error { - err := y.reset() - return trace.Wrap(err) -} - -// 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) { - if err := y.generatePrivateKey(slot, requiredKeyPolicy); err != nil { + attCert, err := y.c.attestationCertificate() + if err != nil { return nil, trace.Wrap(err) } @@ -571,7 +327,20 @@ func (y *YubiKey) generatePrivateKeyAndCert(slot piv.Slot, requiredKeyPolicy Pri return nil, trace.Wrap(err) } - return y.getPrivateKey(slot) + return &hardwarekey.PrivateKeyRef{ + SerialNumber: y.serialNumber, + SlotKey: hardwarekey.PIVSlotKey(slot.Key), + PublicKey: pub, + Policy: policy, + AttestationStatement: &hardwarekey.AttestationStatement{ + AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{ + YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{ + SlotCert: slotCert.Raw, + AttestationCert: attCert.Raw, + }, + }, + }, + }, nil } // SetMetadataCertificate creates a self signed certificate and stores it in the YubiKey's @@ -584,100 +353,69 @@ func (y *YubiKey) SetMetadataCertificate(slot piv.Slot, subject pkix.Name) error return trace.Wrap(err) } - err = y.setCertificate(piv.DefaultManagementKey, slot, cert) + err = y.c.setCertificate(piv.DefaultManagementKey, slot, cert) return trace.Wrap(err) } // getCertificate gets a certificate from the given PIV slot. func (y *YubiKey) getCertificate(slot piv.Slot) (*x509.Certificate, error) { - cert, err := y.certificate(slot) + cert, err := y.c.certificate(slot) return cert, trace.Wrap(err) } -// generatePrivateKey generates a new private key in the given PIV slot. -func (y *YubiKey) generatePrivateKey(slot piv.Slot, requiredKeyPolicy PrivateKeyPolicy) error { - touchPolicy, pinPolicy, err := getKeyPolicies(requiredKeyPolicy) - if err != nil { - return trace.Wrap(err) - } - - opts := piv.Key{ - Algorithm: piv.AlgorithmEC256, - PINPolicy: pinPolicy, - TouchPolicy: touchPolicy, - } - - _, err = y.generateKey(piv.DefaultManagementKey, slot, opts) - return trace.Wrap(err) -} - -// getPrivateKey gets an existing private key from the given PIV slot. -func (y *YubiKey) getPrivateKey(slot piv.Slot) (*PrivateKey, 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()) - } else if err != nil { - return nil, trace.Wrap(err) - } - - attCert, err := y.attestationCertificate() - if err != nil { - return nil, trace.Wrap(err) - } - - attestation, err := piv.Verify(attCert, slotCert) +// attestKey attests the key in the given PIV slot. +// The key's public key can be found in the returned slotCert. +func (y *YubiKey) attestKey(slot piv.Slot) (slotCert *x509.Certificate, attCert *x509.Certificate, att *piv.Attestation, err error) { + slotCert, err = y.c.attest(slot) if err != nil { - return nil, trace.Wrap(err) - } - - priv := &YubiKeyPrivateKey{ - YubiKey: y, - pivSlot: slot, - slotCert: slotCert, - attestationCert: attCert, - attestation: attestation, + return nil, nil, nil, trace.Wrap(err) } - keyPEM, err := priv.keyPEM() + attCert, err = y.c.attestationCertificate() if err != nil { - return nil, trace.Wrap(err) + return nil, nil, nil, trace.Wrap(err) } - key, err := NewPrivateKey(priv, keyPEM) + att, err = piv.Verify(attCert, slotCert) if err != nil { - return nil, trace.Wrap(err) + return nil, nil, nil, trace.Wrap(err) } - cachedKeys[slot] = key - return key, nil + return slotCert, attCert, att, nil } // SetPIN sets the YubiKey PIV PIN. This doesn't require user interaction like touch, just the correct old PIN. func (y *YubiKey) SetPIN(oldPin, newPin string) error { - err := y.setPIN(oldPin, newPin) + err := y.c.setPIN(oldPin, newPin) return trace.Wrap(err) } -// checkOrSetPIN prompts the user for PIN and verifies it with the YubiKey. -// If the user provides the default PIN, they will be prompted to set a -// non-default PIN and PUK before continuing. -func (y *YubiKey) checkOrSetPIN(ctx context.Context) error { - pin, err := y.prompt.AskPIN(ctx, PINOptional) +func (y *YubiKey) setPINAndPUKFromDefault(ctx context.Context, prompt hardwarekey.Prompt, keyInfo hardwarekey.ContextualKeyInfo) (string, error) { + pinAndPUK, err := prompt.ChangePIN(ctx, keyInfo) if err != nil { - return trace.Wrap(err) + return "", trace.Wrap(err) } - switch pin { - case piv.DefaultPIN: - fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) - fallthrough - case "": - if pin, err = y.setPINAndPUKFromDefault(ctx, y.prompt); err != nil { - return trace.Wrap(err) + if err := pinAndPUK.Validate(); err != nil { + return "", trace.Wrap(err) + } + + if pinAndPUK.PUKChanged { + if err := y.c.setPUK(piv.DefaultPUK, pinAndPUK.PUK); err != nil { + return "", trace.Wrap(err) } } - return trace.Wrap(y.verifyPIN(pin)) + if err := y.c.unblock(pinAndPUK.PUK, pinAndPUK.PIN); err != nil { + return "", trace.Wrap(err) + } + + return pinAndPUK.PIN, nil +} + +func (y *YubiKey) verifyPIN(pin string) error { + err := y.c.verifyPIN(pin) + return trace.Wrap(err) } type sharedPIVConnection struct { @@ -771,7 +509,7 @@ func (c *sharedPIVConnection) privateKey(slot piv.Slot, public crypto.PublicKey, return privateKey, trace.Wrap(err) } -func (c *sharedPIVConnection) serial() (uint32, error) { +func (c *sharedPIVConnection) getSerialNumber() (uint32, error) { release, err := c.connect() if err != nil { return 0, trace.Wrap(err) @@ -781,14 +519,21 @@ func (c *sharedPIVConnection) serial() (uint32, error) { return serial, trace.Wrap(err) } +func (c *sharedPIVConnection) getVersion() (piv.Version, error) { + release, err := c.connect() + if err != nil { + return piv.Version{}, trace.Wrap(err) + } + defer release() + return c.conn.Version(), nil +} + func (c *sharedPIVConnection) reset() error { release, err := c.connect() if err != nil { return trace.Wrap(err) } defer release() - // Clear cached keys. - cachedKeys = make(map[piv.Slot]*PrivateKey) return trace.Wrap(c.conn.Reset()) } @@ -877,106 +622,13 @@ func (c *sharedPIVConnection) verifyPIN(pin string) error { return trace.Wrap(c.conn.VerifyPIN(pin)) } -func (c *sharedPIVConnection) setPINAndPUKFromDefault(ctx context.Context, prompt HardwareKeyPrompt) (string, error) { - pinAndPUK, err := prompt.ChangePIN(ctx) - if err != nil { - return "", trace.Wrap(err) - } - // YubiKey requires that PIN and PUK be 6-8 characters. - // Verify that we get valid values from the prompt. - if !isPINLengthValid(pinAndPUK.PIN) { - return "", trace.BadParameter("PIN must be 6-8 characters long") - } - if pinAndPUK.PIN == piv.DefaultPIN { - return "", trace.BadParameter("The default PIN is not supported") - } - if !isPINLengthValid(pinAndPUK.PUK) { - return "", trace.BadParameter("PUK must be 6-8 characters long") - } - if pinAndPUK.PUK == piv.DefaultPUK { - return "", trace.BadParameter("The default PUK is not supported") - } - - if pinAndPUK.PUKChanged { - if err := c.setPUK(piv.DefaultPUK, pinAndPUK.PUK); err != nil { - return "", trace.Wrap(err) - } - } - - if err := c.unblock(pinAndPUK.PUK, pinAndPUK.PIN); err != nil { - return "", trace.Wrap(err) - } - - return pinAndPUK.PIN, nil -} - func isRetryError(err error) bool { const retryError = "connecting to smart card: the smart card cannot be accessed because of other connections outstanding" return strings.Contains(err.Error(), retryError) } -// FindYubiKey finds a yubiKey PIV card by serial number. If no serial -// number is provided, the first yubiKey found will be returned. -func FindYubiKey(serialNumber uint32, prompt HardwareKeyPrompt) (*YubiKey, error) { - yubiKeyCards, err := findYubiKeyCards() - if err != nil { - return nil, trace.Wrap(err) - } - - if len(yubiKeyCards) == 0 { - if serialNumber != 0 { - return nil, trace.ConnectionProblem(nil, "no YubiKey device connected with serial number %d", serialNumber) - } - return nil, trace.ConnectionProblem(nil, "no YubiKey device connected") - } - - for _, card := range yubiKeyCards { - y, err := newYubiKey(card, prompt) - if err != nil { - return nil, trace.Wrap(err) - } - - if serialNumber == 0 || y.serialNumber == serialNumber { - return y, nil - } - } - - return nil, trace.ConnectionProblem(nil, "no YubiKey device connected with serial number %d", serialNumber) -} - -// findYubiKeyCards returns a list of connected yubiKey PIV card names. -func findYubiKeyCards() ([]string, error) { - cards, err := piv.Cards() - if err != nil { - return nil, trace.Wrap(err) - } - - var yubiKeyCards []string - for _, card := range cards { - if strings.Contains(strings.ToLower(card), PIVCardTypeYubiKey) { - yubiKeyCards = append(yubiKeyCards, card) - } - } - - return yubiKeyCards, nil -} - -func (s PIVSlot) validate() error { - _, err := s.parse() - return trace.Wrap(err) -} - -func (s PIVSlot) parse() (piv.Slot, error) { - slotKey, err := strconv.ParseUint(string(s), 16, 32) - if err != nil { - return piv.Slot{}, trace.Wrap(err) - } - - return parsePIVSlot(uint32(slotKey)) -} - -func parsePIVSlot(slotKey uint32) (piv.Slot, error) { - switch slotKey { +func parsePIVSlot(slotKey hardwarekey.PIVSlotKey) (piv.Slot, error) { + switch uint32(slotKey) { case piv.SlotAuthentication.Key: return piv.SlotAuthentication, nil case piv.SlotSignature.Key: @@ -986,11 +638,7 @@ func parsePIVSlot(slotKey uint32) (piv.Slot, error) { case piv.SlotCardAuthentication.Key: return piv.SlotCardAuthentication, nil default: - retiredSlot, ok := piv.RetiredKeyManagementSlot(slotKey) - if !ok { - return piv.Slot{}, trace.BadParameter("slot %X does not exist", slotKey) - } - return retiredSlot, nil + return piv.Slot{}, trace.BadParameter("invalid slot %X", slotKey) } } @@ -1020,11 +668,37 @@ func SelfSignedMetadataCertificate(subject pkix.Name) (*x509.Certificate, error) return cert, nil } -// IsHardware returns true if [k] is a hardware PIV key. -func (k *PrivateKey) IsHardware() bool { - switch k.Signer.(type) { - case *YubiKeyPrivateKey: - return true +// UpdateKeyRef updates the key ref with missing information by querying the hardware key. +// Used for backwards compatibility with old logins. +// TODO(Joerger): DELETE IN v19.0.0 +func UpdateKeyRef(ref *hardwarekey.PrivateKeyRef) error { + y, err := FindYubiKey(ref.SerialNumber) + if err != nil { + return trace.Wrap(err) + } + + pivSlot, err := parsePIVSlot(ref.SlotKey) + if err != nil { + return trace.Wrap(err) + } + + slotCert, attCert, att, err := y.attestKey(pivSlot) + if err != nil { + return trace.Wrap(err) + } + + ref.PublicKey = slotCert.PublicKey + ref.Policy = hardwarekey.PromptPolicy{ + TouchRequired: att.TouchPolicy != piv.TouchPolicyNever, + PINRequired: att.PINPolicy != piv.PINPolicyNever, + } + ref.AttestationStatement = &hardwarekey.AttestationStatement{ + AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{ + YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{ + SlotCert: slotCert.Raw, + AttestationCert: attCert.Raw, + }, + }, } - return false + return nil } diff --git a/api/utils/keys/piv/yubikey_service.go b/api/utils/keys/piv/yubikey_service.go new file mode 100644 index 0000000000000..456fa47dd90ca --- /dev/null +++ b/api/utils/keys/piv/yubikey_service.go @@ -0,0 +1,301 @@ +//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 piv provides a PIV implementation of [hardwarekey.Service]. +package piv + +import ( + "context" + "crypto" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "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" +) + +// The PIV daemon only allows a single PC/SC transaction (connection) at a time, +// so we cache the YubiKey connection for re-use across the process. +// +// TODO(Joerger): Rather than using a global cache, clients should be updated to +// create a single YubiKeyService and ensure it is reused across the program +// execution. +var ( + yubiKeys map[uint32]*YubiKey = map[uint32]*YubiKey{} + yubiKeysMux sync.Mutex +) + +// YubiKeyService is a YubiKey PIV implementation of [hardwarekey.Service]. +type YubiKeyService struct { + // promptMux is used during to prevent over-prompting, especially for back-to-back sign requests + // since touch/PIN from the first signature should be cached for following signatures. + promptMux sync.Mutex + prompt hardwarekey.Prompt + + // ctx is provided to signature requests since the [crypto.Signer] interface + // does not have context support directly. + ctx context.Context +} + +// Returns a new [YubiKeyService]. +// +// 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(ctx context.Context, prompt hardwarekey.Prompt) *YubiKeyService { + if ctx == nil { + ctx = context.Background() + } + + if prompt == nil { + prompt = &hardwarekey.CLIPrompt{} + } + + return &YubiKeyService{ + ctx: ctx, + prompt: prompt, + } +} + +// NewPrivateKey creates or retrieves a hardware private key from the given PIV slot matching +// the given policy and returns the details required to perform signatures with that key. +// +// If a customSlot is not provided, 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.PrivateKey, error) { + // Use the first yubiKey we find. + y, err := s.getYubiKey(0) + if err != nil { + return nil, trace.Wrap(err) + } + + // Get the requested or default PIV slot. + var slotKey hardwarekey.PIVSlotKey + if config.CustomSlot != "" { + slotKey, err = config.CustomSlot.Parse() + } else { + slotKey, err = hardwarekey.GetDefaultSlotKey(config.Policy) + } + if err != nil { + return nil, trace.Wrap(err) + } + + pivSlot, err := parsePIVSlot(slotKey) + if err != nil { + return nil, trace.Wrap(err) + } + + // If PIN is required, check that PIN and PUK are not the defaults. + if config.Policy.PINRequired { + if err := s.checkOrSetPIN(ctx, y, config.ContextualKeyInfo); err != nil { + return nil, trace.Wrap(err) + } + } + + generatePrivateKey := func() (*hardwarekey.PrivateKey, error) { + ref, err := y.generatePrivateKey(pivSlot, config.Policy) + if err != nil { + return nil, trace.Wrap(err) + } + return hardwarekey.NewPrivateKey(s, ref, config.ContextualKeyInfo), nil + } + + // If a custom slot was not specified, check for a key in the + // default slot for the given policy and generate a new one if needed. + if config.CustomSlot == "" { + switch cert, err := y.getCertificate(pivSlot); { + case errors.Is(err, piv.ErrNotFound): + return generatePrivateKey() + + case err != nil: + return nil, trace.Wrap(err) + + // Unknown cert found, this slot could be in use by a non-teleport client. + // Prompt the user before we overwrite the slot. + case len(cert.Subject.Organization) == 0 || cert.Subject.Organization[0] != certOrgName: + if err := s.promptOverwriteSlot(ctx, nonTeleportCertificateMessage(pivSlot, cert), config.ContextualKeyInfo); err != nil { + return nil, trace.Wrap(err) + } + return generatePrivateKey() + } + } + + // Check for an existing key in the slot that satisfies the required + // prompt policy, or generate a new one if needed. + slotCert, attCert, att, err := y.attestKey(pivSlot) + switch { + case errors.Is(err, piv.ErrNotFound): + return generatePrivateKey() + + case err != nil: + return nil, trace.Wrap(err) + + case config.Policy.TouchRequired && att.TouchPolicy == piv.TouchPolicyNever: + msg := fmt.Sprintf("private key in YubiKey PIV slot %q does not require touch.", pivSlot) + if err := s.promptOverwriteSlot(ctx, msg, config.ContextualKeyInfo); err != nil { + return nil, trace.Wrap(err) + } + return generatePrivateKey() + + case config.Policy.PINRequired && att.PINPolicy == piv.PINPolicyNever: + msg := fmt.Sprintf("private key in YubiKey PIV slot %q does not require PIN", pivSlot) + if err := s.promptOverwriteSlot(ctx, msg, config.ContextualKeyInfo); err != nil { + return nil, trace.Wrap(err) + } + return generatePrivateKey() + } + + return hardwarekey.NewPrivateKey(s, &hardwarekey.PrivateKeyRef{ + SerialNumber: y.serialNumber, + SlotKey: slotKey, + PublicKey: slotCert.PublicKey, + Policy: hardwarekey.PromptPolicy{ + TouchRequired: att.TouchPolicy != piv.TouchPolicyNever, + PINRequired: att.PINPolicy != piv.PINPolicyNever, + }, + AttestationStatement: &hardwarekey.AttestationStatement{ + AttestationStatement: &attestationv1.AttestationStatement_YubikeyAttestationStatement{ + YubikeyAttestationStatement: &attestationv1.YubiKeyAttestationStatement{ + SlotCert: slotCert.Raw, + AttestationCert: attCert.Raw, + }, + }, + }, + }, config.ContextualKeyInfo), 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, keyInfo hardwarekey.ContextualKeyInfo, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + // Usually, Sign will be called without context through the [crypto.Signer] interface, + // so we opportunistically set the context. + if ctx == context.TODO() { + ctx = s.ctx + } + + y, err := s.getYubiKey(ref.SerialNumber) + if err != nil { + return nil, trace.Wrap(err) + } + + s.promptMux.Lock() + defer s.promptMux.Unlock() + + return y.sign(ctx, ref, keyInfo, s.prompt, 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.promptMux.Lock() + defer s.promptMux.Unlock() + s.prompt = prompt +} + +// Get the given YubiKey with the serial number. If the provided serialNumber is 0, +// return the first YubiKey found in the smart card list. +func (s *YubiKeyService) getYubiKey(serialNumber uint32) (*YubiKey, error) { + yubiKeysMux.Lock() + defer yubiKeysMux.Unlock() + + if y, ok := yubiKeys[serialNumber]; ok { + return y, nil + } + + y, err := FindYubiKey(serialNumber) + if err != nil { + return nil, trace.Wrap(err) + } + + yubiKeys[y.serialNumber] = y + return y, nil +} + +// checkOrSetPIN prompts the user for PIN and verifies it with the YubiKey. +// If the user provides the default PIN, they will be prompted to set a +// non-default PIN and PUK before continuing. +func (s *YubiKeyService) checkOrSetPIN(ctx context.Context, y *YubiKey, keyInfo hardwarekey.ContextualKeyInfo) error { + s.promptMux.Lock() + defer s.promptMux.Unlock() + + pin, err := s.prompt.AskPIN(ctx, hardwarekey.PINOptional, keyInfo) + if err != nil { + return trace.Wrap(err) + } + + switch pin { + case piv.DefaultPIN: + fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) + fallthrough + case "": + pin, err = y.setPINAndPUKFromDefault(ctx, s.prompt, keyInfo) + if err != nil { + return trace.Wrap(err) + } + } + + return trace.Wrap(y.verifyPIN(pin)) +} + +func (s *YubiKeyService) promptOverwriteSlot(ctx context.Context, msg string, keyInfo hardwarekey.ContextualKeyInfo) error { + s.promptMux.Lock() + defer s.promptMux.Unlock() + + promptQuestion := fmt.Sprintf("%v\nWould you like to overwrite this slot's private key and certificate?", msg) + if confirmed, confirmErr := s.prompt.ConfirmSlotOverwrite(ctx, promptQuestion, keyInfo); confirmErr != nil { + return trace.Wrap(confirmErr) + } else if !confirmed { + return trace.Wrap(trace.CompareFailed(msg), "user declined to overwrite slot") + } + return nil +} + +func nonTeleportCertificateMessage(slot piv.Slot, cert *x509.Certificate) string { + // Gather a small list of user-readable x509 certificate fields to display to the user. + sum := sha256.Sum256(cert.Raw) + fingerPrint := hex.EncodeToString(sum[:]) + return fmt.Sprintf(`Certificate in YubiKey PIV slot %q is not a Teleport client cert: +Slot %s: + Algorithm: %v + Subject DN: %v + Issuer DN: %v + Serial: %v + Fingerprint: %v + Not before: %v + Not after: %v +`, + slot, slot, + cert.SignatureAlgorithm, + cert.Subject, + cert.Issuer, + cert.SerialNumber, + fingerPrint, + cert.NotBefore, + cert.NotAfter, + ) +} diff --git a/api/utils/keys/piv/yubikey_service_fake.go b/api/utils/keys/piv/yubikey_service_fake.go new file mode 100644 index 0000000000000..538a21d76f8aa --- /dev/null +++ b/api/utils/keys/piv/yubikey_service_fake.go @@ -0,0 +1,148 @@ +//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 piv + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "io" + "sync" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" +) + +// TODO(Joerger): Instead of using a distinct build tag for tests, tests should inject +// a mock hardware key service, e.g. into the CLI conf. + +// TODO(Joerger): Rather than using a global cache, clients should be updated to +// create a single YubiKeyService and ensure it is reused across the program +// execution. +var ( + hardwarePrivateKeys = map[hardwareKeySlot]*fakeHardwarePrivateKey{} + hardwarePrivateKeysMux sync.Mutex +) + +// 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 *hardwarekey.PrivateKeyRef +} + +// hardwareKeySlot references a specific hardware key slot on a specific hardware key. +type hardwareKeySlot struct { + serialNumber uint32 + slot hardwarekey.PIVSlotKey +} + +type fakeYubiKeyPIVService struct{} + +func NewYubiKeyService(_ context.Context, _ hardwarekey.Prompt) *fakeYubiKeyPIVService { + return &fakeYubiKeyPIVService{} +} + +func (s *fakeYubiKeyPIVService) NewPrivateKey(ctx context.Context, config hardwarekey.PrivateKeyConfig) (*hardwarekey.PrivateKey, error) { + hardwarePrivateKeysMux.Lock() + defer hardwarePrivateKeysMux.Unlock() + + // Get the requested or default PIV slot. + var slotKey hardwarekey.PIVSlotKey + var err error + if config.CustomSlot != "" { + slotKey, err = config.CustomSlot.Parse() + } else { + slotKey, err = hardwarekey.GetDefaultSlotKey(config.Policy) + } + if err != nil { + return nil, trace.Wrap(err) + } + + keySlot := hardwareKeySlot{ + serialNumber: serialNumber, + slot: slotKey, + } + + if priv, ok := hardwarePrivateKeys[keySlot]; ok { + return hardwarekey.NewPrivateKey(s, priv.ref), nil + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, trace.Wrap(err) + } + + ref := &hardwarekey.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 AttestHardwareKey. + AttestationStatement: &hardwarekey.AttestationStatement{}, + ContextualKeyInfo: config.ContextualKeyInfo, + } + + hardwarePrivateKeys[keySlot] = &fakeHardwarePrivateKey{ + Signer: priv, + ref: ref, + } + + return hardwarekey.NewPrivateKey(s, ref), nil +} + +// Sign performs a cryptographic signature using the specified hardware +// private key and provided signature parameters. +func (s *fakeYubiKeyPIVService) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, _ hardwarekey.ContextualKeyInfo, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + hardwarePrivateKeysMux.Lock() + defer hardwarePrivateKeysMux.Unlock() + + priv, ok := hardwarePrivateKeys[hardwareKeySlot{ + serialNumber: serialNumber, + slot: ref.SlotKey, + }] + if !ok { + return nil, trace.NotFound("key not found in slot %d", ref.SlotKey) + } + + return priv.Sign(rand, digest, opts) +} + +func (s *fakeYubiKeyPIVService) SetPrompt(prompt hardwarekey.Prompt) {} + +// TODO(Joerger): DELETE IN v19.0.0 +func UpdateKeyRef(ref *hardwarekey.PrivateKeyRef) error { + hardwarePrivateKeysMux.Lock() + defer hardwarePrivateKeysMux.Unlock() + + priv, ok := hardwarePrivateKeys[hardwareKeySlot{ + serialNumber: serialNumber, + slot: ref.SlotKey, + }] + if !ok { + return trace.NotFound("key not found in slot %d", ref.SlotKey) + } + + *ref = *priv.ref + return nil +} diff --git a/api/utils/keys/yubikey_test.go b/api/utils/keys/piv/yubikey_service_test.go similarity index 59% rename from api/utils/keys/yubikey_test.go rename to api/utils/keys/piv/yubikey_service_test.go index 72ac01537041c..3a3eb934e5ca7 100644 --- a/api/utils/keys/yubikey_test.go +++ b/api/utils/keys/piv/yubikey_service_test.go @@ -13,28 +13,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -package keys_test +package piv_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" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/api/utils/prompt" ) // 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") } @@ -44,13 +44,25 @@ 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() + + s := piv.NewYubiKeyService(ctx, &hardwarekey.CLIPrompt{}) - y, err := keys.FindYubiKey(0, nil) + y, err := piv.FindYubiKey(0) require.NoError(t, err) + resetYubikey(t, y) t.Cleanup(func() { resetYubikey(t, y) }) + // 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.Nil(t, priv.WarmupHardwareKey(ctx)) + for _, policy := range []keys.PrivateKeyPolicy{ keys.PrivateKeyPolicyHardwareKey, keys.PrivateKeyPolicyHardwareKeyTouch, @@ -63,31 +75,42 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) { resetYubikey(t, y) setupPINPrompt(t, y) - var slot keys.PIVSlot = "" + var slot hardwarekey.PIVSlotKeyString = "" if customSlot { 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: hardwarekey.PromptPolicy{ + TouchRequired: policy.IsHardwareKeyTouchVerified(), + PINRequired: policy.IsHardwareKeyPINVerified(), + }, + }) require.NoError(t, err) // test HardwareSigner methods require.Equal(t, policy, priv.GetPrivateKeyPolicy()) require.NotNil(t, priv.GetAttestationStatement()) - - // Test Sign. - digest := []byte{100} - _, err = priv.Sign(rand.Reader, digest, nil) - require.NoError(t, err) - - // Another call to GetYubiKeyPrivateKey should retrieve the previously generated key. - retrievePriv, err := keys.GetYubiKeyPrivateKey(ctx, policy, slot, nil) + require.True(t, priv.IsHardware()) + + // Test bogus sign (warmup). + require.Nil(t, priv.WarmupHardwareKey(ctx)) + + // Another call to NewHardwarePrivateKey should retrieve the previously generated key. + retrievePriv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{ + CustomSlot: slot, + Policy: hardwarekey.PromptPolicy{ + TouchRequired: policy.IsHardwareKeyTouchVerified(), + PINRequired: policy.IsHardwareKeyPINVerified(), + }, + }) require.NoError(t, err) require.Equal(t, priv.Public(), retrievePriv.Public()) // parsing the key's private key PEM should produce the same key as well. - retrievePriv, err = keys.ParsePrivateKey(priv.PrivateKeyPEM()) + retrievePriv, err = keys.ParsePrivateKey(priv.PrivateKeyPEM(), keys.WithHardwareKeyService(s)) require.NoError(t, err) require.Equal(t, priv.Public(), retrievePriv.Public()) }) @@ -97,32 +120,36 @@ 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() + s := piv.NewYubiKeyService(ctx, &hardwarekey.CLIPrompt{}) - y, err := keys.FindYubiKey(0, nil) + y, err := piv.FindYubiKey(0) require.NoError(t, err) + resetYubikey(t, y) t.Cleanup(func() { resetYubikey(t, y) }) - // Use a custom slot. - pivSlot, err := keys.GetDefaultKeySlot(keys.PrivateKeyPolicyHardwareKeyTouch) - require.NoError(t, err) + // Get the default slot used for hardware_key_touch. + 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) } @@ -130,7 +157,7 @@ func TestOverwritePrompt(t *testing.T) { resetYubikey(t, y) // Set a non-teleport certificate in the slot. - err = y.SetMetadataCertificate(pivSlot, pkix.Name{Organization: []string{"not-teleport"}}) + err = y.SetMetadataCertificate(touchSlot, pkix.Name{Organization: []string{"not-teleport"}}) require.NoError(t, err) testOverwritePrompt(t) @@ -140,7 +167,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, keys.PIVSlot(pivSlot.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) @@ -148,17 +178,17 @@ func TestOverwritePrompt(t *testing.T) { } // resetYubikey connects to the first yubiKey and resets it to defaults. -func resetYubikey(t *testing.T, y *keys.YubiKey) { +func resetYubikey(t *testing.T, y *piv.YubiKey) { t.Helper() require.NoError(t, y.Reset()) } -func setupPINPrompt(t *testing.T, y *keys.YubiKey) { +func setupPINPrompt(t *testing.T, y *piv.YubiKey) { t.Helper() // 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/yubikey_unavailable.go b/api/utils/keys/piv/yubikey_unavailable.go new file mode 100644 index 0000000000000..f52a5cd7f7e16 --- /dev/null +++ b/api/utils/keys/piv/yubikey_unavailable.go @@ -0,0 +1,52 @@ +//go:build !piv && !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 piv + +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(ctx context.Context, _ hardwarekey.Prompt) *unavailableYubiKeyPIVService { + return &unavailableYubiKeyPIVService{} +} + +type unavailableYubiKeyPIVService struct{} + +func (s *unavailableYubiKeyPIVService) NewPrivateKey(_ context.Context, _ hardwarekey.PrivateKeyConfig) (*hardwarekey.PrivateKey, 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, _ hardwarekey.ContextualKeyInfo, _ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) { + return nil, trace.Wrap(errPIVUnavailable) +} + +func (s *unavailableYubiKeyPIVService) SetPrompt(_ hardwarekey.Prompt) {} + +// TODO(Joerger): DELETE IN v19.0.0 +func UpdateKeyRef(ref *hardwarekey.PrivateKeyRef) error { + return trace.Wrap(errPIVUnavailable) +} diff --git a/api/utils/keys/policy.go b/api/utils/keys/policy.go index 60ab361559261..85455ddc96972 100644 --- a/api/utils/keys/policy.go +++ b/api/utils/keys/policy.go @@ -66,17 +66,17 @@ func (requiredPolicy PrivateKeyPolicy) IsSatisfiedBy(keyPolicy PrivateKeyPolicy) case PrivateKeyPolicyHardwareKey: return keyPolicy.IsHardwareKeyPolicy() case PrivateKeyPolicyHardwareKeyTouch: - return keyPolicy.isHardwareKeyTouchVerified() + return keyPolicy.IsHardwareKeyTouchVerified() case PrivateKeyPolicyHardwareKeyPIN: - return keyPolicy.isHardwareKeyPINVerified() + return keyPolicy.IsHardwareKeyPINVerified() case PrivateKeyPolicyHardwareKeyTouchAndPIN: - return keyPolicy.isHardwareKeyTouchVerified() && keyPolicy.isHardwareKeyPINVerified() + return keyPolicy.IsHardwareKeyTouchVerified() && keyPolicy.IsHardwareKeyPINVerified() } return false } -func (p PrivateKeyPolicy) isHardwareKeyTouchVerified() bool { +func (p PrivateKeyPolicy) IsHardwareKeyTouchVerified() bool { switch p { case PrivateKeyPolicyHardwareKeyTouch, PrivateKeyPolicyHardwareKeyTouchAndPIN: return true @@ -84,7 +84,7 @@ func (p PrivateKeyPolicy) isHardwareKeyTouchVerified() bool { return false } -func (p PrivateKeyPolicy) isHardwareKeyPINVerified() bool { +func (p PrivateKeyPolicy) IsHardwareKeyPINVerified() bool { switch p { case PrivateKeyPolicyHardwareKeyPIN, PrivateKeyPolicyHardwareKeyTouchAndPIN: return true @@ -111,7 +111,7 @@ func (p PrivateKeyPolicy) IsHardwareKeyPolicy() bool { // of the connection, during the TLS/SSH handshake. For long term connections, MFA should // be re-verified through other methods (e.g. webauthn). func (p PrivateKeyPolicy) MFAVerified() bool { - return p.isHardwareKeyTouchVerified() || p.isHardwareKeyPINVerified() + return p.IsHardwareKeyTouchVerified() || p.IsHardwareKeyPINVerified() } func (p PrivateKeyPolicy) validate() error { @@ -189,3 +189,13 @@ func IsPrivateKeyPolicyError(err error) bool { } return privateKeyPolicyErrRegex.MatchString(err.Error()) } + +// AttestationData is attested information about the hardware private key matching the 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 1255acb6f7df2..d9e52c0038447 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" @@ -31,6 +32,8 @@ import ( "github.com/gravitational/trace" "golang.org/x/crypto/ssh" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/api/utils/sshutils/ppk" ) @@ -58,14 +61,24 @@ type PrivateKey struct { keyPEM []byte } -// NewPrivateKey returns a new PrivateKey for the given crypto.Signer with a +// NewPrivateKey returns a new PrivateKey for a crypto.Signer. +// [signer] must be an *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, or *hardwarekey.PrivateKey. +// TODO(Joerger): Remove the variadic argument once /e is updated to not provide it. +func NewPrivateKey(signer crypto.Signer, _ ...[]byte) (*PrivateKey, error) { + keyPEM, err := MarshalPrivateKey(signer) + if err != nil { + return nil, trace.Wrap(err) + } + return newPrivateKeyWithKeyPEM(signer, keyPEM) +} + +// newPrivateKeyWithKeyPEM returns a new PrivateKey for the given crypto.Signer with a // pre-marshaled private key PEM, which may be a special PIV key PEM. -func NewPrivateKey(signer crypto.Signer, keyPEM []byte) (*PrivateKey, error) { +func newPrivateKeyWithKeyPEM(signer crypto.Signer, keyPEM []byte) (*PrivateKey, error) { sshPub, err := ssh.NewPublicKey(signer.Public()) if err != nil { return nil, trace.Wrap(err) } - return &PrivateKey{ Signer: signer, sshPub: sshPub, @@ -73,22 +86,20 @@ func NewPrivateKey(signer crypto.Signer, keyPEM []byte) (*PrivateKey, error) { }, nil } -// NewSoftwarePrivateKey returns a new PrivateKey for a crypto.Signer. -// [signer] must be an *rsa.PrivateKey, *ecdsa.PrivateKey, or ed25519.PrivateKey. -func NewSoftwarePrivateKey(signer crypto.Signer) (*PrivateKey, error) { - sshPub, err := ssh.NewPublicKey(signer.Public()) - if err != nil { - return nil, trace.Wrap(err) +// NewHardwarePrivateKey creates or retrieves a hardware private key from from the given hardware key +// service that matches the given PIV slot and private key policy, returning the hardware private key +// 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") } - keyPEM, err := MarshalPrivateKey(signer) + + hwPrivateKey, err := s.NewPrivateKey(ctx, keyConfig) if err != nil { return nil, trace.Wrap(err) } - return &PrivateKey{ - Signer: signer, - sshPub: sshPub, - keyPEM: keyPEM, - }, nil + + return NewPrivateKey(hwPrivateKey) } // SSHPublicKey returns the ssh.PublicKey representation of the public key. @@ -191,6 +202,51 @@ func (k *PrivateKey) SoftwarePrivateKeyPEM() ([]byte, error) { return nil, trace.BadParameter("cannot get software key PEM for private key of type %T", k.Signer) } +// GetAttestationStatement returns this key's AttestationStatement. If the key is +// not a [hardwarekey.PrivateKey], this method returns nil. +func (k *PrivateKey) GetAttestationStatement() *hardwarekey.AttestationStatement { + if hwpk, ok := k.Signer.(*hardwarekey.PrivateKey); 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.PrivateKey); ok { + switch hwpk.GetPromptPolicy() { + case hardwarekey.PromptPolicyNone: + return PrivateKeyPolicyHardwareKey + + case hardwarekey.PromptPolicyTouch: + return PrivateKeyPolicyHardwareKeyTouch + + case hardwarekey.PromptPolicyPIN: + return PrivateKeyPolicyHardwareKeyPIN + + case hardwarekey.PromptPolicyTouchAndPIN: + return PrivateKeyPolicyHardwareKeyTouchAndPIN + } + } + + return PrivateKeyPolicyNone +} + +// IsHardware returns true if [k] is a [hardwarekey.PrivateKey]. +func (k *PrivateKey) IsHardware() bool { + _, ok := k.Signer.(*hardwarekey.PrivateKey) + return ok +} + +// WarmupHardwareKey checks if this is a [hardwarekey.PrivateKey] and warms it up if it is. +func (k *PrivateKey) WarmupHardwareKey(ctx context.Context) error { + if hwpk, ok := k.Signer.(*hardwarekey.PrivateKey); ok { + return hwpk.WarmupHardwareKey(ctx) + } + return nil +} + // LoadPrivateKey returns the PrivateKey for the given key file. func LoadPrivateKey(keyFile string) (*PrivateKey, error) { keyPEM, err := os.ReadFile(keyFile) @@ -207,19 +263,26 @@ func LoadPrivateKey(keyFile string) (*PrivateKey, error) { // ParsePrivateKeyOptions contains config options for ParsePrivateKey. type ParsePrivateKeyOptions struct { - // CustomHardwareKeyPrompt is a custom hardware key prompt to use when asking - // for a hardware key PIN, touch, etc. - // If empty, a default CLI prompt is used. - CustomHardwareKeyPrompt HardwareKeyPrompt + // HardwareKeyService is the hardware key service to use with parsed hardware private keys. + HardwareKeyService hardwarekey.Service + // ContextualKeyInfo is contextual information associated with the key. + ContextualKeyInfo hardwarekey.ContextualKeyInfo } // ParsePrivateKeyOpt applies configuration options. type ParsePrivateKeyOpt func(o *ParsePrivateKeyOptions) -// WithCustomPrompt sets a custom hardware key prompt. -func WithCustomPrompt(prompt HardwareKeyPrompt) ParsePrivateKeyOpt { +// WithHardwareKeyService sets the hardware key service. +func WithHardwareKeyService(hwKeyService hardwarekey.Service) ParsePrivateKeyOpt { + return func(o *ParsePrivateKeyOptions) { + o.HardwareKeyService = hwKeyService + } +} + +// WithContextualKeyInfo adds contextual key info to the parsed private key. +func WithContextualKeyInfo(info hardwarekey.ContextualKeyInfo) ParsePrivateKeyOpt { return func(o *ParsePrivateKeyOptions) { - o.CustomHardwareKeyPrompt = prompt + o.ContextualKeyInfo = info } } @@ -238,8 +301,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") + if appliedOpts.HardwareKeyService == nil { + return nil, trace.BadParameter("cannot parse hardware private key without an initialized hardware key service") + } + + hwPrivateKey, err := hardwarekey.DecodePrivateKey(appliedOpts.HardwareKeyService, block.Bytes, appliedOpts.ContextualKeyInfo, piv.UpdateKeyRef) + if err != nil { + return nil, trace.Wrap(err, "failed to parse hardware private key") + } + + return newPrivateKeyWithKeyPEM(hwPrivateKey, keyPEM) case OpenSSHPrivateKeyType: priv, err := ssh.ParseRawPrivateKey(keyPEM) if err != nil { @@ -255,7 +326,7 @@ func ParsePrivateKey(keyPEM []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, er if pEdwards, ok := cryptoSigner.(*ed25519.PrivateKey); ok { cryptoSigner = *pEdwards } - return NewPrivateKey(cryptoSigner, keyPEM) + return newPrivateKeyWithKeyPEM(cryptoSigner, keyPEM) case PKCS1PrivateKeyType, PKCS8PrivateKeyType, ECPrivateKeyType: // The DER format doesn't always exactly match the PEM header, various // versions of Teleport and OpenSSL have been guilty of writing PKCS#8 @@ -267,17 +338,17 @@ func ParsePrivateKey(keyPEM []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, er if !ok { return nil, trace.BadParameter("x509.ParsePKCS8PrivateKey returned an invalid private key of type %T", priv) } - return NewPrivateKey(signer, keyPEM) + return newPrivateKeyWithKeyPEM(signer, keyPEM) } else if block.Type == PKCS8PrivateKeyType { preferredErr = err } if signer, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return NewPrivateKey(signer, keyPEM) + return newPrivateKeyWithKeyPEM(signer, keyPEM) } else if block.Type == PKCS1PrivateKeyType { preferredErr = err } if signer, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return NewPrivateKey(signer, keyPEM) + return newPrivateKeyWithKeyPEM(signer, keyPEM) } else if block.Type == ECPrivateKeyType { preferredErr = err } @@ -310,13 +381,23 @@ func MarshalPrivateKey(key crypto.Signer) ([]byte, error) { Bytes: der, }) return privPEM, nil + case *hardwarekey.PrivateKey: + encodedKey, err := privateKey.Encode() + 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) } } // LoadKeyPair returns the PrivateKey for the given private and public key files. -func LoadKeyPair(privFile, sshPubFile string, customPrompt HardwareKeyPrompt) (*PrivateKey, error) { +func LoadKeyPair(privFile, sshPubFile string, opts ...ParsePrivateKeyOpt) (*PrivateKey, error) { privPEM, err := os.ReadFile(privFile) if err != nil { return nil, trace.ConvertSystemError(err) @@ -327,7 +408,7 @@ func LoadKeyPair(privFile, sshPubFile string, customPrompt HardwareKeyPrompt) (* return nil, trace.ConvertSystemError(err) } - priv, err := ParseKeyPair(privPEM, marshaledSSHPub, customPrompt) + priv, err := ParseKeyPair(privPEM, marshaledSSHPub, opts...) if err != nil { return nil, trace.Wrap(err) } @@ -335,8 +416,8 @@ func LoadKeyPair(privFile, sshPubFile string, customPrompt HardwareKeyPrompt) (* } // ParseKeyPair returns the PrivateKey for the given private and public key PEM blocks. -func ParseKeyPair(privPEM, marshaledSSHPub []byte, customPrompt HardwareKeyPrompt) (*PrivateKey, error) { - priv, err := ParsePrivateKey(privPEM, WithCustomPrompt(customPrompt)) +func ParseKeyPair(privPEM, marshaledSSHPub []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, error) { + priv, err := ParsePrivateKey(privPEM, opts...) if err != nil { return nil, trace.Wrap(err) } diff --git a/api/utils/keys/privatekey_test.go b/api/utils/keys/privatekey_test.go index 6e4759eb44a2c..51af7ce7057ee 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" @@ -33,6 +36,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" ) func TestMarshalAndParseKey(t *testing.T) { @@ -44,21 +51,26 @@ func TestMarshalAndParseKey(t *testing.T) { _, edKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) + s := piv.NewYubiKeyService(context.TODO(), 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, keys.WithHardwareKeyService(s)) require.NoError(t, err) require.Equal(t, key, gotKey.Signer) - 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) }) @@ -66,7 +78,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) @@ -116,7 +128,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) }) @@ -142,7 +154,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) }) @@ -161,7 +173,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) }) } @@ -172,7 +184,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) }) } @@ -206,7 +218,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"))) @@ -266,7 +278,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) @@ -274,7 +286,61 @@ 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.NewPrivateKey(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 := piv.NewYubiKeyService(ctx, nil) + 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)) +} + +// TODO(Joerger): DELETE in v19.0.0 +func TestHardwareKey_OldLogin(t *testing.T) { + s := piv.NewYubiKeyService(context.TODO(), nil) + hwPriv, err := s.NewPrivateKey(context.TODO(), hardwarekey.PrivateKeyConfig{ + CustomSlot: "9a", + }) + require.NoError(t, err) + + // If an old client logged in, the private key ref would only contain the PIV + // slot (and serial number which is irrelevant with pivtest). + hwPrivMissingInfo := hardwarekey.NewPrivateKey(s, &hardwarekey.PrivateKeyRef{ + SlotKey: 0x9a, + }) + keyPEM, err := keys.MarshalPrivateKey(hwPrivMissingInfo) + require.NoError(t, err) + require.NotEqual(t, hwPriv, hwPrivMissingInfo) + + // ParsePrivateKey should automatically get the missing hardware key info + // from the direct PIV implementation of [piv.UpdateKeyRef]. + parsedKey, err := keys.ParsePrivateKey(keyPEM, keys.WithHardwareKeyService(s)) + require.NoError(t, err) + require.Equal(t, hwPriv, parsedKey.Signer) } var ( diff --git a/api/utils/keys/yubikey_common.go b/api/utils/keys/yubikey_common.go deleted file mode 100644 index 5ed36f814580d..0000000000000 --- a/api/utils/keys/yubikey_common.go +++ /dev/null @@ -1,81 +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" -) - -// HardwareKeyPrompt provides methods to interact with a YubiKey hardware key. -type HardwareKeyPrompt interface { - // AskPIN prompts the user for a PIN. - // The requirement tells if the PIN is required or optional. - AskPIN(ctx context.Context, requirement PINPromptRequirement) (string, error) - // Touch prompts the user to touch the hardware key. - Touch(ctx context.Context) error - // ChangePIN asks for a new PIN. - // If the PUK has a default value, it should ask for the new value for it. - // It is up to the implementer how the validation is handled. - // For example, CLI prompt can ask for a valid PIN/PUK in a loop, a GUI - // prompt can use the frontend validation. - ChangePIN(ctx context.Context) (*PINAndPUK, error) - // ConfirmSlotOverwrite asks the user if the slot's private key and certificate can be overridden. - ConfirmSlotOverwrite(ctx context.Context, message string) (bool, error) -} - -// PINPromptRequirement specifies whether a PIN is required. -type PINPromptRequirement int - -const ( - // PINOptional allows the user to proceed without entering a PIN. - PINOptional PINPromptRequirement = iota - // PINRequired enforces that a PIN must be entered to proceed. - PINRequired -) - -// PINAndPUK describes a response returned from HardwareKeyPrompt.ChangePIN. -type PINAndPUK struct { - // New PIN set by the user. - PIN string - // PUK used to change the PIN. - // This is a new PUK if it has not been changed (from the default PUK). - PUK string - // PUKChanged is true if the user changed the default PUK. - PUKChanged bool -} - -// 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 PIVSlot, customPrompt HardwareKeyPrompt) (*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 -} - -// PIVSlot is the string representation of a PIV slot. e.g. "9a". -type PIVSlot string - -// Validate that the PIV slot is a valid value. -func (s PIVSlot) Validate() error { - return trace.Wrap(s.validate()) -} diff --git a/api/utils/keys/yubikey_fake.go b/api/utils/keys/yubikey_fake.go deleted file mode 100644 index 33be8917815d5..0000000000000 --- a/api/utils/keys/yubikey_fake.go +++ /dev/null @@ -1,84 +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" -) - -var errPIVUnavailable = errors.New("PIV is unavailable in current build") - -// Return a fake YubiKey private key. -func getOrGenerateYubiKeyPrivateKey(_ context.Context, policy PrivateKeyPolicy, _ PIVSlot, _ HardwareKeyPrompt) (*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, _ HardwareKeyPrompt) (*PrivateKey, error) { - // TODO(Joerger): add custom marshal/unmarshal logic for fakeYubiKeyPrivateKey (if necessary). - return nil, trace.Wrap(errPIVUnavailable) -} - -func (s PIVSlot) validate() error { - return 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 -} - -// IsHardware returns true if [k] is a hardware PIV key. -func (k *PrivateKey) IsHardware() bool { - switch k.Signer.(type) { - case *fakeYubiKeyPrivateKey: - return true - } - return false -} diff --git a/api/utils/keys/yubikey_other.go b/api/utils/keys/yubikey_other.go deleted file mode 100644 index 77d7de29a2ea8..0000000000000 --- a/api/utils/keys/yubikey_other.go +++ /dev/null @@ -1,43 +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" -) - -var errPIVUnavailable = errors.New("PIV is unavailable in current build") - -func getOrGenerateYubiKeyPrivateKey(ctx context.Context, policy PrivateKeyPolicy, slot PIVSlot, _ HardwareKeyPrompt) (*PrivateKey, error) { - return nil, trace.Wrap(errPIVUnavailable) -} - -func parseYubiKeyPrivateKeyData(keyDataBytes []byte, _ HardwareKeyPrompt) (*PrivateKey, error) { - return nil, trace.Wrap(errPIVUnavailable) -} - -func (s PIVSlot) validate() error { - return trace.Wrap(errPIVUnavailable) -} - -// IsHardware returns true if [k] is a hardware PIV key. -func (k *PrivateKey) IsHardware() bool { - // Built without PIV support - this must be false. - return false -} diff --git a/e b/e index 24ad965db73c3..ccba574b0619b 160000 --- a/e +++ b/e @@ -1 +1 @@ -Subproject commit 24ad965db73c347ba97793b0f3fc0005e1ec7d1a +Subproject commit ccba574b0619babb93e7cae55ff16a695907b207 diff --git a/integration/autoupdate/tools/updater/modules.go b/integration/autoupdate/tools/updater/modules.go index 6c1236393af78..5aac39757a2e4 100644 --- a/integration/autoupdate/tools/updater/modules.go +++ b/integration/autoupdate/tools/updater/modules.go @@ -31,6 +31,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/tlsca" @@ -99,7 +100,7 @@ func (p *TestModules) IsBoringBinary() bool { } // AttestHardwareKey attests a hardware key. -func (p *TestModules) AttestHardwareKey(context.Context, interface{}, *keys.AttestationStatement, crypto.PublicKey, time.Duration) (*keys.AttestationData, error) { +func (p *TestModules) AttestHardwareKey(context.Context, interface{}, *hardwarekey.AttestationStatement, crypto.PublicKey, time.Duration) (*keys.AttestationData, error) { return nil, trace.NotFound("no attestation data for the given key") } diff --git a/integration/helpers/helpers.go b/integration/helpers/helpers.go index 54a35b0a32084..170e2d161c287 100644 --- a/integration/helpers/helpers.go +++ b/integration/helpers/helpers.go @@ -197,9 +197,9 @@ func MustCreateUserKeyRing(t *testing.T, tc *TeleInstance, username string, ttl } func mustCreateUserKeyRingWithKeys(t *testing.T, tc *TeleInstance, username string, ttl time.Duration, sshKey, tlsKey crypto.Signer) *client.KeyRing { - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) require.NoError(t, err) - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) require.NoError(t, err) keyRing := client.NewKeyRing(sshPriv, tlsPriv) keyRing.ClusterName = tc.Secrets.SiteName diff --git a/integration/helpers/usercreds.go b/integration/helpers/usercreds.go index 6f6c2192b7d0b..742f38a090ff0 100644 --- a/integration/helpers/usercreds.go +++ b/integration/helpers/usercreds.go @@ -120,11 +120,11 @@ func GenerateUserCreds(req UserCredsRequest) (*UserCreds, error) { if err != nil { return nil, trace.Wrap(err) } - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) if err != nil { return nil, trace.Wrap(err) } - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) if err != nil { return nil, trace.Wrap(err) } diff --git a/integration/proxy/teleterm_test.go b/integration/proxy/teleterm_test.go index 915fa1208a0d9..263ead5f3f221 100644 --- a/integration/proxy/teleterm_test.go +++ b/integration/proxy/teleterm_test.go @@ -40,7 +40,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/piv" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" "github.com/gravitational/teleport/integration/appaccess" dbhelpers "github.com/gravitational/teleport/integration/db" @@ -246,11 +246,9 @@ func testGatewayCertRenewal(ctx context.Context, t *testing.T, params gatewayCer InsecureSkipVerify: tc.InsecureSkipVerify, // Inject a fake clock into clusters.Storage so we can control when the middleware thinks the // db cert has expired. - Clock: fakeClock, - WebauthnLogin: webauthnLogin, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + Clock: fakeClock, + WebauthnLogin: webauthnLogin, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -882,9 +880,7 @@ func testTeletermAppGatewayTargetPortValidation(t *testing.T, pack *appaccess.Pa storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) daemonService, err := daemon.New(daemon.Config{ diff --git a/integration/teleterm_test.go b/integration/teleterm_test.go index baad6e2871e7e..76fa25b674516 100644 --- a/integration/teleterm_test.go +++ b/integration/teleterm_test.go @@ -43,7 +43,7 @@ import ( "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/piv" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" dbhelpers "github.com/gravitational/teleport/integration/db" "github.com/gravitational/teleport/integration/helpers" @@ -258,9 +258,7 @@ func testAddingRootCluster(t *testing.T, pack *dbhelpers.DatabasePack, creds *he storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) @@ -293,9 +291,7 @@ func testListRootClustersReturnsLoggedInUser(t *testing.T, pack *dbhelpers.Datab storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) @@ -378,9 +374,7 @@ func testGetClusterReturnsPropertiesFromAuthServer(t *testing.T, pack *dbhelpers storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) @@ -433,9 +427,7 @@ func testHeadlessWatcher(t *testing.T, pack *dbhelpers.DatabasePack, creds *help storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -504,9 +496,7 @@ func testClientCache(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers. Dir: tc.KeysDir, Clock: storageFakeClock, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -766,9 +756,7 @@ func testCreateConnectMyComputerRole(t *testing.T, pack *dbhelpers.DatabasePack) storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -885,9 +873,7 @@ func testCreateConnectMyComputerToken(t *testing.T, pack *dbhelpers.DatabasePack InsecureSkipVerify: tc.InsecureSkipVerify, Clock: fakeClock, WebauthnLogin: webauthnLogin, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -948,9 +934,7 @@ func testWaitForConnectMyComputerNodeJoin(t *testing.T, pack *dbhelpers.Database storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -1035,9 +1019,7 @@ func testDeleteConnectMyComputerNode(t *testing.T, pack *dbhelpers.DatabasePack) storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -1265,9 +1247,7 @@ func testListDatabaseUsers(t *testing.T, pack *dbhelpers.DatabasePack) { storage, err := clusters.NewStorage(clusters.Config{ Dir: tc.KeysDir, InsecureSkipVerify: tc.InsecureSkipVerify, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) diff --git a/lib/agentless/agentless.go b/lib/agentless/agentless.go index c7346a4370765..1107fecd81b2e 100644 --- a/lib/agentless/agentless.go +++ b/lib/agentless/agentless.go @@ -177,7 +177,7 @@ func createAuthSigner(ctx context.Context, params certParams, localAccessPoint L if err != nil { return nil, trace.Wrap(err) } - priv, err := keys.NewSoftwarePrivateKey(key) + priv, err := keys.NewPrivateKey(key) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/auth.go b/lib/auth/auth.go index ebb249be6bfc8..43071b3d1ce82 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -80,6 +80,7 @@ import ( "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/retryutils" apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/entitlements" @@ -2208,9 +2209,9 @@ type certRequest struct { // TLS certificate. tlsPublicKey []byte // sshPublicKeyAttestationStatement is an attestation statement associated with sshPublicKey. - sshPublicKeyAttestationStatement *keys.AttestationStatement + sshPublicKeyAttestationStatement *hardwarekey.AttestationStatement // tlsPublicKeyAttestationStatement is an attestation statement associated with tlsPublicKey. - tlsPublicKeyAttestationStatement *keys.AttestationStatement + tlsPublicKeyAttestationStatement *hardwarekey.AttestationStatement // user is a user to generate certificate for user services.UserState @@ -2453,8 +2454,8 @@ type GenerateUserTestCertsRequest struct { RouteToCluster string PinnedIP string MFAVerified string - SSHAttestationStatement *keys.AttestationStatement - TLSAttestationStatement *keys.AttestationStatement + SSHAttestationStatement *hardwarekey.AttestationStatement + TLSAttestationStatement *hardwarekey.AttestationStatement AppName string AppSessionID string } @@ -3476,7 +3477,7 @@ func generateCert(ctx context.Context, a *Server, req certRequest, caType types. type attestHardwareKeyParams struct { requiredKeyPolicy keys.PrivateKeyPolicy pubKey crypto.PublicKey - attestationStatement *keys.AttestationStatement + attestationStatement *hardwarekey.AttestationStatement sessionTTL time.Duration readOnlyAuthPref readonly.AuthPreference userName string diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index 67fa7130f93a3..0080cdb2af27e 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -48,7 +48,7 @@ import ( apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/clusterconfig/clusterconfigv1" @@ -3447,9 +3447,9 @@ func (a *ServerWithRoles) generateUserCerts(ctx context.Context, req proto.UserC return nil, trace.Wrap(err) } sshAttestationStatement, tlsAttestationStatement := authclient.UserAttestationStatements( - keys.AttestationStatementFromProto(req.AttestationStatement), //nolint:staticcheck // SA1019. Checking deprecated field that may be sent by older clients. - keys.AttestationStatementFromProto(req.SSHPublicKeyAttestationStatement), - keys.AttestationStatementFromProto(req.TLSPublicKeyAttestationStatement), + hardwarekey.AttestationStatementFromProto(req.AttestationStatement), //nolint:staticcheck // SA1019. Checking deprecated field that may be sent by older clients. + hardwarekey.AttestationStatementFromProto(req.SSHPublicKeyAttestationStatement), + hardwarekey.AttestationStatementFromProto(req.TLSPublicKeyAttestationStatement), ) // Generate certificate, note that the roles TTL will be ignored because diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 1e638d7561aeb..5d4c8766a79b6 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -63,6 +63,7 @@ import ( "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" accessgraphv1 "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha" wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" "github.com/gravitational/teleport/lib/defaults" @@ -1426,7 +1427,7 @@ func UserPublicKeys(pubIn, sshPubIn, tlsPubIn []byte) (sshPubOut, tlsPubOut []by // sshAttIn and tlsAttIn will be returned. // [sshAttIn] and [tlsAttIn] should be the SSH and TLS attestation statements // set by any post-17.0.0 client. -func UserAttestationStatements(attIn, sshAttIn, tlsAttIn *keys.AttestationStatement) (sshAttOut, tlsAttOut *keys.AttestationStatement) { +func UserAttestationStatements(attIn, sshAttIn, tlsAttIn *hardwarekey.AttestationStatement) (sshAttOut, tlsAttOut *hardwarekey.AttestationStatement) { if attIn == nil { return sshAttIn, tlsAttIn } @@ -1469,14 +1470,14 @@ type AuthenticateSSHRequest struct { // AttestationStatement is an attestation statement associated with the given public key. // // Deprecated: prefer SSHAttestationStatement and/or TLSAttestationStatement. - AttestationStatement *keys.AttestationStatement `json:"attestation_statement,omitempty"` + AttestationStatement *hardwarekey.AttestationStatement `json:"attestation_statement,omitempty"` // SSHAttestationStatement is an attestation statement associated with the // given SSH public key. - SSHAttestationStatement *keys.AttestationStatement `json:"ssh_attestation_statement,omitempty"` + SSHAttestationStatement *hardwarekey.AttestationStatement `json:"ssh_attestation_statement,omitempty"` // TLSAttestationStatement is an attestation statement associated with the // given TLS public key. - TLSAttestationStatement *keys.AttestationStatement `json:"tls_attestation_statement,omitempty"` + TLSAttestationStatement *hardwarekey.AttestationStatement `json:"tls_attestation_statement,omitempty"` } // CheckAndSetDefaults checks and sets default certificate values diff --git a/lib/auth/github.go b/lib/auth/github.go index a9b834722f21f..1d626d4e02e51 100644 --- a/lib/auth/github.go +++ b/lib/auth/github.go @@ -40,7 +40,7 @@ import ( apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/client/sso" @@ -699,9 +699,9 @@ func (a *Server) makeGithubAuthResponse( return nil, trace.Wrap(err) } sshAttestationStatement, tlsAttestationStatement := authclient.UserAttestationStatements( - keys.AttestationStatementFromProto(req.AttestationStatement), //nolint:staticcheck // SA1019. Checking deprecated field that may be sent by older clients. - keys.AttestationStatementFromProto(req.SshAttestationStatement), - keys.AttestationStatementFromProto(req.TlsAttestationStatement), + hardwarekey.AttestationStatementFromProto(req.AttestationStatement), //nolint:staticcheck // SA1019. Checking deprecated field that may be sent by older clients. + hardwarekey.AttestationStatementFromProto(req.SshAttestationStatement), + hardwarekey.AttestationStatementFromProto(req.TlsAttestationStatement), ) if len(sshPublicKey)+len(tlsPublicKey) > 0 { sshCert, tlsCert, err := a.CreateSessionCerts(ctx, &SessionCertsRequest{ diff --git a/lib/auth/keygen/keygen_test.go b/lib/auth/keygen/keygen_test.go index e82933b944885..9e5c65213a3b5 100644 --- a/lib/auth/keygen/keygen_test.go +++ b/lib/auth/keygen/keygen_test.go @@ -106,7 +106,7 @@ func TestBuildPrincipals(t *testing.T) { hostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) require.NoError(t, err) - hostPrivateKey, err := keys.NewSoftwarePrivateKey(hostKey) + hostPrivateKey, err := keys.NewPrivateKey(hostKey) require.NoError(t, err) hostPublicKey := hostPrivateKey.MarshalSSHPublicKey() diff --git a/lib/auth/sessions.go b/lib/auth/sessions.go index caf98e262b0f8..a1d0958b7f51e 100644 --- a/lib/auth/sessions.go +++ b/lib/auth/sessions.go @@ -35,6 +35,7 @@ import ( "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" @@ -700,8 +701,8 @@ type SessionCertsRequest struct { SessionTTL time.Duration SSHPubKey []byte TLSPubKey []byte - SSHAttestationStatement *keys.AttestationStatement - TLSAttestationStatement *keys.AttestationStatement + SSHAttestationStatement *hardwarekey.AttestationStatement + TLSAttestationStatement *hardwarekey.AttestationStatement Compatibility string RouteToCluster string KubernetesCluster string diff --git a/lib/client/api.go b/lib/client/api.go index da2382162d683..db1c252554013 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -71,6 +71,8 @@ import ( apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/grpc/interceptors" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/api/utils/prompt" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/touchid" @@ -447,7 +449,7 @@ type Config struct { PrivateKeyPolicy keys.PrivateKeyPolicy // PIVSlot specifies a specific PIV slot to use with hardware key support. - PIVSlot keys.PIVSlot + PIVSlot hardwarekey.PIVSlotKeyString // LoadAllCAs indicates that tsh should load the CAs of all clusters // instead of just the current cluster. @@ -494,11 +496,6 @@ type Config struct { // SSOMFACeremonyConstructor is a custom SSO MFA ceremony constructor. SSOMFACeremonyConstructor func(rd *sso.Redirector) mfa.SSOMFACeremony - // CustomHardwareKeyPrompt is a custom hardware key prompt to use when asking - // for a hardware key PIN, touch, etc. - // If empty, a default CLI prompt is used. - CustomHardwareKeyPrompt keys.HardwareKeyPrompt - // DisableSSHResumption disables transparent SSH connection resumption. DisableSSHResumption bool @@ -1282,12 +1279,12 @@ func NewClient(c *Config) (tc *TeleportClient, err error) { if tc.TLS != nil || tc.AuthMethods != nil { // Client will use static auth methods instead of client store. // Initialize empty client store to prevent panics. - tc.ClientStore = NewMemClientStore() + tc.ClientStore = NewMemClientStore(nil /*hwKeyService*/) } else { - tc.ClientStore = NewFSClientStore(c.KeysDir) - if c.CustomHardwareKeyPrompt != nil { - tc.ClientStore.SetCustomHardwareKeyPrompt(tc.CustomHardwareKeyPrompt) - } + // TODO (Joerger): init hardware key service (and client store) earlier where it can + // be properly shared. + hardwareKeyService := piv.NewYubiKeyService(context.TODO(), &hardwarekey.CLIPrompt{}) + tc.ClientStore = NewFSClientStore(c.KeysDir, hardwareKeyService) if c.AddKeysToAgent == AddKeysToAgentOnly { // Store client keys in memory, but still save trusted certs and profile to disk. tc.ClientStore.KeyStore = NewMemKeyStore() @@ -4002,7 +3999,18 @@ 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) + priv, err := tc.ClientStore.NewHardwarePrivateKey(ctx, hardwarekey.PrivateKeyConfig{ + CustomSlot: tc.PIVSlot, + Policy: hardwarekey.PromptPolicy{ + TouchRequired: tc.PrivateKeyPolicy.IsHardwareKeyTouchVerified(), + PINRequired: tc.PrivateKeyPolicy.IsHardwareKeyPINVerified(), + }, + ContextualKeyInfo: hardwarekey.ContextualKeyInfo{ + ProxyHost: tc.WebProxyHost(), + Username: tc.Username, + ClusterName: tc.SiteName, + }, + }) if err != nil { return nil, trace.Wrap(err) } @@ -4030,11 +4038,11 @@ func (tc *TeleportClient) GetNewLoginKeyRing(ctx context.Context) (keyRing *KeyR } } - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) if err != nil { return nil, trace.Wrap(err) } - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/api_test.go b/lib/client/api_test.go index 248f6979049c6..aeac510ca4d90 100644 --- a/lib/client/api_test.go +++ b/lib/client/api_test.go @@ -1054,7 +1054,7 @@ func TestRootClusterName(t *testing.T) { }, { name: "key store", modifyCfg: func(c *Config) { - c.ClientStore = NewMemClientStore() + c.ClientStore = NewMemClientStore(nil /*hwKeyService*/) err := c.ClientStore.AddKeyRing(keyRing) require.NoError(t, err) }, @@ -1113,7 +1113,7 @@ func TestLoadTLSConfigForClusters(t *testing.T) { name: "key store no clusters", clusters: []string{}, modifyCfg: func(c *Config) { - c.ClientStore = NewMemClientStore() + c.ClientStore = NewMemClientStore(nil /*hwKeyService*/) err := c.ClientStore.AddKeyRing(keyRing) require.NoError(t, err) }, @@ -1122,7 +1122,7 @@ func TestLoadTLSConfigForClusters(t *testing.T) { name: "key store root cluster", clusters: []string{rootCluster}, modifyCfg: func(c *Config) { - c.ClientStore = NewMemClientStore() + c.ClientStore = NewMemClientStore(nil /*hwKeyService*/) err := c.ClientStore.AddKeyRing(keyRing) require.NoError(t, err) }, @@ -1131,7 +1131,7 @@ func TestLoadTLSConfigForClusters(t *testing.T) { name: "key store unknown clusters", clusters: []string{"leaf-1", "leaf-2"}, modifyCfg: func(c *Config) { - c.ClientStore = NewMemClientStore() + c.ClientStore = NewMemClientStore(nil /*hwKeyService*/) err := c.ClientStore.AddKeyRing(keyRing) require.NoError(t, err) }, diff --git a/lib/client/client_store.go b/lib/client/client_store.go index 5762f9d7ec8ee..97032d57f3465 100644 --- a/lib/client/client_store.go +++ b/lib/client/client_store.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/utils" ) @@ -43,7 +44,8 @@ import ( // when using `tsh --add-keys-to-agent=only`, Store will be made up of an in-memory // key store and an FS (~/.tsh) profile and trusted certs store. type Store struct { - log *slog.Logger + log *slog.Logger + hwKeyService hardwarekey.Service KeyStore TrustedCertsStore @@ -51,10 +53,11 @@ type Store struct { } // NewMemClientStore initializes an FS backed client store with the given base dir. -func NewFSClientStore(dirPath string) *Store { +func NewFSClientStore(dirPath string, hwKeyService hardwarekey.Service) *Store { dirPath = profile.FullProfilePath(dirPath) return &Store{ log: slog.With(teleport.ComponentKey, teleport.ComponentKeyStore), + hwKeyService: hwKeyService, KeyStore: NewFSKeyStore(dirPath), TrustedCertsStore: NewFSTrustedCertsStore(dirPath), ProfileStore: NewFSProfileStore(dirPath), @@ -62,15 +65,21 @@ func NewFSClientStore(dirPath string) *Store { } // NewMemClientStore initializes a new in-memory client store. -func NewMemClientStore() *Store { +func NewMemClientStore(hwKeyService hardwarekey.Service) *Store { return &Store{ log: slog.With(teleport.ComponentKey, teleport.ComponentKeyStore), + hwKeyService: hwKeyService, KeyStore: NewMemKeyStore(), TrustedCertsStore: NewMemTrustedCertsStore(), ProfileStore: NewMemProfileStore(), } } +// NewHardwarePrivateKey create a new hardware private key with the given configuration in this client store. +func (s *Store) NewHardwarePrivateKey(ctx context.Context, config hardwarekey.PrivateKeyConfig) (*keys.PrivateKey, error) { + return keys.NewHardwarePrivateKey(ctx, s.hwKeyService, config) +} + // AddKeyRing adds the given key ring to the key store. The key's trusted certificates are // added to the trusted certs store. func (s *Store) AddKeyRing(keyRing *KeyRing) error { @@ -83,12 +92,6 @@ func (s *Store) AddKeyRing(keyRing *KeyRing) error { return nil } -// SetCustomHardwareKeyPrompt sets a custom hardware key prompt -// used to interact with a YubiKey private key. -func (s *Store) SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) { - s.KeyStore.SetCustomHardwareKeyPrompt(prompt) -} - // ErrNoProfile is returned by the client store when a specific profile is not found. var ErrNoProfile = &trace.NotFoundError{Message: "no profile"} @@ -121,7 +124,7 @@ func IsNoCredentialsError(err error) bool { // certs store. If the key ring is not found or is missing data (certificates, etc.), // then an ErrNoCredentials error is returned. func (s *Store) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing, error) { - keyRing, err := s.KeyStore.GetKeyRing(idx, opts...) + keyRing, err := s.KeyStore.GetKeyRing(idx, s.hwKeyService, opts...) if trace.IsNotFound(err) { return nil, newNoCredentialsError(err) } else if err != nil { diff --git a/lib/client/client_store_test.go b/lib/client/client_store_test.go index 65d5429272cad..bbff3d7d5b2b9 100644 --- a/lib/client/client_store_test.go +++ b/lib/client/client_store_test.go @@ -76,9 +76,9 @@ func (s *testAuthority) makeSignedKeyRing(t *testing.T, idx KeyRingIndex, makeEx return types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1, nil }) require.NoError(t, err) - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) require.NoError(t, err) - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) require.NoError(t, err) allowedLogins := []string{idx.Username, "root"} @@ -160,7 +160,7 @@ func newSelfSignedCA(privateKey []byte, cluster string) (*tlsca.CertAuthority, a } func newTestFSClientStore(t *testing.T) *Store { - fsClientStore := NewFSClientStore(t.TempDir()) + fsClientStore := NewFSClientStore(t.TempDir(), nil /*hwKeyService*/) return fsClientStore } @@ -170,7 +170,7 @@ func testEachClientStore(t *testing.T, testFunc func(t *testing.T, clientStore * }) t.Run("Mem", func(t *testing.T) { - testFunc(t, NewMemClientStore()) + testFunc(t, NewMemClientStore(nil /*hwKeyService*/)) }) } @@ -198,7 +198,7 @@ func TestClientStore(t *testing.T) { require.Equal(t, keyRing.TrustedCerts, retrievedTrustedCerts) // Getting the key from the key store should have no trusted certs. - retrievedKeyRing, err := clientStore.KeyStore.GetKeyRing(idx, WithAllCerts...) + retrievedKeyRing, err := clientStore.KeyStore.GetKeyRing(idx, clientStore.hwKeyService, WithAllCerts...) require.NoError(t, err) expectKeyRing := keyRing.Copy() expectKeyRing.TrustedCerts = nil @@ -400,9 +400,7 @@ func BenchmarkLoadKeysToKubeFromStore(b *testing.B) { certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) require.NotEmpty(b, certPEM) - keyPEM, err := keys.MarshalPrivateKey(key) - require.NoError(b, err) - privateKey, err := keys.NewPrivateKey(key, keyPEM) + privateKey, err := keys.NewPrivateKey(key) require.NoError(b, err) kubeCred := TLSCredential{ @@ -464,7 +462,7 @@ func BenchmarkLoadKeysToKubeFromStore(b *testing.B) { for _, kubeClusterName := range kubeClusterNames { go func() { defer wg.Done() - keyRing, err := fsKeyStore.GetKeyRing(keyRing.KeyRingIndex, WithKubeCerts{}) + keyRing, err := fsKeyStore.GetKeyRing(keyRing.KeyRingIndex, nil /*hwks*/, WithKubeCerts{}) require.NoError(b, err) require.NotNil(b, keyRing.KubeTLSCredentials[kubeClusterName].PrivateKey) require.NotEmpty(b, keyRing.KubeTLSCredentials[kubeClusterName].Cert) diff --git a/lib/client/cluster_client.go b/lib/client/cluster_client.go index 903d68c7d08c0..b75ccf1c018e0 100644 --- a/lib/client/cluster_client.go +++ b/lib/client/cluster_client.go @@ -35,6 +35,7 @@ import ( mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/resumption" @@ -393,7 +394,7 @@ func (c *ClusterClient) prepareUserCertsRequest(ctx context.Context, params Reis } var sshPub, tlsPub []byte - var sshAttestationStatement, tlsAttestationStatement *keys.AttestationStatement + var sshAttestationStatement, tlsAttestationStatement *hardwarekey.AttestationStatement if sshSubjectKey != nil { sshPub = sshSubjectKey.MarshalSSHPublicKey() sshAttestationStatement = sshSubjectKey.GetAttestationStatement() diff --git a/lib/client/cluster_client_test.go b/lib/client/cluster_client_test.go index e529b4737d1db..e30f4832c0c85 100644 --- a/lib/client/cluster_client_test.go +++ b/lib/client/cluster_client_test.go @@ -95,7 +95,7 @@ func TestIssueUserCertsWithMFA(t *testing.T) { clock := clockwork.NewFakeClock() agent, err := NewLocalAgent(LocalAgentConfig{ - ClientStore: NewMemClientStore(), + ClientStore: NewMemClientStore(nil /*hwKeyService*/), ProxyHost: "test", Username: "alice", Insecure: true, @@ -278,7 +278,7 @@ func TestIssueUserCertsWithMFA(t *testing.T) { { name: "no keys loaded", agent: &LocalKeyAgent{ - clientStore: NewMemClientStore(), + clientStore: NewMemClientStore(nil /*hwKeyService*/), }, assertion: func(t *testing.T, keyRing *KeyRing, mfaRequired proto.MFARequired, err error) { require.Error(t, err) diff --git a/lib/client/conntest/ssh.go b/lib/client/conntest/ssh.go index 948e7ab873eed..db485e45b901a 100644 --- a/lib/client/conntest/ssh.go +++ b/lib/client/conntest/ssh.go @@ -114,7 +114,7 @@ func (s *SSHConnectionTester) TestConnection(ctx context.Context, req TestConnec if err != nil { return nil, trace.Wrap(err) } - privateKey, err := keys.NewSoftwarePrivateKey(key) + privateKey, err := keys.NewPrivateKey(key) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/db/database_certificates.go b/lib/client/db/database_certificates.go index af75dd3b29f11..ed37daff9d9ce 100644 --- a/lib/client/db/database_certificates.go +++ b/lib/client/db/database_certificates.go @@ -103,7 +103,7 @@ func GenerateDatabaseServerCertificates(ctx context.Context, req GenerateDatabas if err != nil { return nil, trace.Wrap(err) } - privateKey, err := keys.NewSoftwarePrivateKey(key) + privateKey, err := keys.NewPrivateKey(key) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/db/oracle/oracle_test.go b/lib/client/db/oracle/oracle_test.go index 6bde950afb870..220c41ee0b4fe 100644 --- a/lib/client/db/oracle/oracle_test.go +++ b/lib/client/db/oracle/oracle_test.go @@ -40,7 +40,7 @@ func TestCreateJksWallet(t *testing.T) { publicPEM, err := keys.MarshalPublicKey(signer.Public()) require.NoError(t, err) - wrapped, err := keys.NewSoftwarePrivateKey(signer) + wrapped, err := keys.NewPrivateKey(signer) require.NoError(t, err) _, err = createJKSWallet(signer, publicPEM, publicPEM, "dummy") diff --git a/lib/client/identityfile/identity.go b/lib/client/identityfile/identity.go index ad929ce4cc218..2c519a7a99caf 100644 --- a/lib/client/identityfile/identity.go +++ b/lib/client/identityfile/identity.go @@ -41,6 +41,7 @@ import ( "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/prompt" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/kube/kubeconfig" @@ -838,8 +839,8 @@ func KeyRingFromIdentityFile(identityPath, proxyHost, clusterName string) (*clie // This is necessary because identity files do not store the proxy address. // Additionally, the [clusterName] argument can ve used to target a leaf cluster // rather than the default root cluster. -func NewClientStoreFromIdentityFile(identityFile, proxyAddr, clusterName string) (*client.Store, error) { - clientStore := client.NewMemClientStore() +func NewClientStoreFromIdentityFile(identityFile, proxyAddr, clusterName string, hwKeyService hardwarekey.Service) (*client.Store, error) { + clientStore := client.NewMemClientStore(hwKeyService) if err := LoadIdentityFileIntoClientStore(clientStore, identityFile, proxyAddr, clusterName); err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/identityfile/identity_test.go b/lib/client/identityfile/identity_test.go index fe1d9df9a9857..9207ad62cd676 100644 --- a/lib/client/identityfile/identity_test.go +++ b/lib/client/identityfile/identity_test.go @@ -439,7 +439,7 @@ func TestNewClientStoreFromIdentityFile(t *testing.T) { }) require.NoError(t, err) - clientStore, err := NewClientStoreFromIdentityFile(identityFilePath, keyRing.ProxyHost+":3080", keyRing.ClusterName) + clientStore, err := NewClientStoreFromIdentityFile(identityFilePath, keyRing.ProxyHost+":3080", keyRing.ClusterName, nil /*hwKeyService*/) require.NoError(t, err) currentProfile, err := clientStore.CurrentProfile() diff --git a/lib/client/interfaces.go b/lib/client/interfaces.go index c7299cf3824bc..2e0d6256aab57 100644 --- a/lib/client/interfaces.go +++ b/lib/client/interfaces.go @@ -40,6 +40,7 @@ import ( "github.com/gravitational/teleport/api/constants" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/cryptosuites" @@ -90,6 +91,14 @@ func (idx KeyRingIndex) LogValue() slog.Value { ) } +func (idx KeyRingIndex) contextualKeyInfo() hardwarekey.ContextualKeyInfo { + return hardwarekey.ContextualKeyInfo{ + ProxyHost: idx.ProxyHost, + Username: idx.Username, + ClusterName: idx.ClusterName, + } +} + // TLSCredential holds a signed TLS certificate and matching private key. type TLSCredential struct { // PrivateKey is the private key of the credential. @@ -162,7 +171,7 @@ func (k *KeyRing) generateSubjectTLSKey(ctx context.Context, tc *TeleportClient, if err != nil { return nil, trace.Wrap(err) } - priv, err := keys.NewSoftwarePrivateKey(key) + priv, err := keys.NewPrivateKey(key) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/keyagent_test.go b/lib/client/keyagent_test.go index b937812f49ddb..c580dcdc37d92 100644 --- a/lib/client/keyagent_test.go +++ b/lib/client/keyagent_test.go @@ -330,7 +330,7 @@ func TestHostCertVerification(t *testing.T) { s := makeSuite(t) // Make a new local agent. - clientStore := NewFSClientStore(s.keyDir) + clientStore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) lka, err := NewLocalAgent(LocalAgentConfig{ ClientStore: clientStore, ProxyHost: s.hostname, @@ -471,7 +471,7 @@ func TestHostKeyVerification(t *testing.T) { s := makeSuite(t) // make a new local agent - keystore := NewFSClientStore(s.keyDir) + keystore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) lka, err := NewLocalAgent(LocalAgentConfig{ ClientStore: keystore, ProxyHost: s.hostname, @@ -562,7 +562,7 @@ func TestHostCertVerificationLoadAllCasProxyAddrEqClusterName(t *testing.T) { ) s := makeSuite(t, withClusterName(rootClusterName), withHostname(rootClusterName)) - clientStore := NewFSClientStore(s.keyDir) + clientStore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) lka, err := NewLocalAgent(LocalAgentConfig{ ClientStore: clientStore, ProxyHost: proxyHost, @@ -646,7 +646,7 @@ func TestDefaultHostPromptFunc(t *testing.T) { keygen := testauthority.New() - clientStore := NewFSClientStore(s.keyDir) + clientStore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) a, err := NewLocalAgent(LocalAgentConfig{ ClientStore: clientStore, ProxyHost: s.hostname, @@ -694,7 +694,7 @@ func TestLocalKeyAgent_AddDatabaseKey(t *testing.T) { s := makeSuite(t) // make a new local agent - clientStore := NewFSClientStore(s.keyDir) + clientStore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) lka, err := NewLocalAgent( LocalAgentConfig{ ClientStore: clientStore, @@ -772,9 +772,9 @@ func (s *KeyAgentTestSuite) makeKeyRing(t *testing.T, username, proxyHost string }) require.NoError(t, err) - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) require.NoError(t, err) - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) require.NoError(t, err) return &KeyRing{ @@ -855,7 +855,7 @@ func startDebugAgent(t *testing.T) error { func (s *KeyAgentTestSuite) newKeyAgent(t *testing.T) *LocalKeyAgent { // make a new local agent - clientStore := NewFSClientStore(s.keyDir) + clientStore := NewFSClientStore(s.keyDir, nil /*hwKeyService*/) keyAgent, err := NewLocalAgent(LocalAgentConfig{ ClientStore: clientStore, ProxyHost: s.hostname, diff --git a/lib/client/keystore.go b/lib/client/keystore.go index 43fa9b05c253e..59427c7cda46b 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -39,6 +39,7 @@ import ( "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/utils" ) @@ -73,7 +74,7 @@ type KeyStore interface { // GetKeyRing returns the user's key ring including the specified certs. The // key's TrustedCerts will be nil and should be filled in using a // TrustedCertsStore. - GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing, error) + GetKeyRing(idx KeyRingIndex, hwks hardwarekey.Service, opts ...CertOption) (*KeyRing, error) // DeleteKeyRing deletes the user's key with all its certs. DeleteKeyRing(idx KeyRingIndex) error @@ -88,10 +89,6 @@ type KeyStore interface { // GetSSHCertificates gets all certificates signed for the given user and proxy, // including certificates for trusted clusters. GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) - - // SetCustomHardwareKeyPrompt sets a custom hardware key prompt - // used to interact with a YubiKey private key. - SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) } // FSKeyStore is an on-disk implementation of the KeyStore interface. @@ -103,10 +100,6 @@ type FSKeyStore struct { // KeyDir is the directory where all keys are stored. KeyDir string - // CustomHardwareKeyPrompt is a custom hardware key prompt to use when asking - // for a hardware key PIN, touch, etc. - // If nil, a default CLI prompt is used. - CustomHardwareKeyPrompt keys.HardwareKeyPrompt } // NewFSKeyStore initializes a new FSClientStore. @@ -193,12 +186,6 @@ func (fs *FSKeyStore) kubeCredPath(idx KeyRingIndex, kubename string) string { return keypaths.KubeCredPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, kubename) } -// SetCustomHardwareKeyPrompt sets a custom hardware key prompt -// used to interact with a YubiKey private key. -func (fs *FSKeyStore) SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) { - fs.CustomHardwareKeyPrompt = prompt -} - // AddKeyRing adds the given key ring to the store. func (fs *FSKeyStore) AddKeyRing(keyRing *KeyRing) error { if err := keyRing.KeyRingIndex.Check(); err != nil { @@ -301,12 +288,12 @@ func (fs *FSKeyStore) writeTLSCredential(cred TLSCredential, keyPath, certPath s return nil } -func readTLSCredential(keyPath, certPath string, customPrompt keys.HardwareKeyPrompt) (TLSCredential, error) { +func readTLSCredential(keyPath, certPath string, opts ...keys.ParsePrivateKeyOpt) (TLSCredential, error) { keyPEM, certPEM, err := readTLSCredentialFiles(keyPath, certPath) if err != nil { return TLSCredential{}, trace.Wrap(err) } - key, err := keys.ParsePrivateKey(keyPEM, keys.WithCustomPrompt(customPrompt)) + key, err := keys.ParsePrivateKey(keyPEM, opts...) if err != nil { return TLSCredential{}, trace.Wrap(err) } @@ -390,12 +377,12 @@ func (fs *FSKeyStore) writeKubeCredential(cred TLSCredential, path string) error // readKubeCredential reads a kube key and cert from a single file written by // [(*FSKeyStore).writeKubeCredential]. Compared to using separate files it is // more efficient for reading/writing and avoids file locks. -func readKubeCredential(path string) (TLSCredential, error) { +func readKubeCredential(path string, opts ...keys.ParsePrivateKeyOpt) (TLSCredential, error) { keyPEM, certPEM, err := readKubeCredentialFile(path) if err != nil { return TLSCredential{}, trace.Wrap(err) } - privateKey, err := keys.ParsePrivateKey(keyPEM) + privateKey, err := keys.ParsePrivateKey(keyPEM, opts...) if err != nil { return TLSCredential{}, trace.Wrap(err) } @@ -538,7 +525,7 @@ func (e *LegacyCertPathError) Unwrap() error { // GetKeyRing returns the user's key including the specified certs. // If the key is not found, returns trace.NotFound error. -func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing, error) { +func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, hwks hardwarekey.Service, opts ...CertOption) (*KeyRing, error) { if len(opts) > 0 { if err := idx.Check(); err != nil { return nil, trace.Wrap(err, "GetKeyRing with CertOptions requires a fully specified KeyRingIndex") @@ -549,7 +536,12 @@ func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing return nil, trace.Wrap(err, "no session keys for %+v", idx) } - tlsCred, err := readTLSCredential(fs.userTLSKeyPath(idx), fs.tlsCertPath(idx), fs.CustomHardwareKeyPrompt) + tlsCred, err := readTLSCredential( + fs.userTLSKeyPath(idx), + fs.tlsCertPath(idx), + keys.WithHardwareKeyService(hwks), + keys.WithContextualKeyInfo(idx.contextualKeyInfo()), + ) if err != nil { if trace.IsNotFound(err) { if _, statErr := os.Stat(fs.tlsCertPathLegacy(idx)); statErr == nil { @@ -560,7 +552,12 @@ func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing return nil, trace.Wrap(err) } - sshPriv, err := keys.LoadKeyPair(fs.userSSHKeyPath(idx), fs.publicKeyPath(idx), fs.CustomHardwareKeyPrompt) + sshPriv, err := keys.LoadKeyPair( + fs.userSSHKeyPath(idx), + fs.publicKeyPath(idx), + keys.WithHardwareKeyService(hwks), + keys.WithContextualKeyInfo(idx.contextualKeyInfo()), + ) if err != nil { return nil, trace.ConvertSystemError(err) } @@ -570,7 +567,7 @@ func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing keyRing.TLSCert = tlsCred.Cert for _, o := range opts { - if err := fs.updateKeyRingWithCerts(o, keyRing); err != nil && !trace.IsNotFound(err) { + if err := fs.updateKeyRingWithCerts(o, hwks, keyRing); err != nil && !trace.IsNotFound(err) { return nil, trace.Wrap(err) } } @@ -582,8 +579,14 @@ func (fs *FSKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing return keyRing, nil } -func (fs *FSKeyStore) updateKeyRingWithCerts(o CertOption, keyRing *KeyRing) error { - return trace.Wrap(o.updateKeyRing(fs.KeyDir, keyRing.KeyRingIndex, keyRing, fs.CustomHardwareKeyPrompt)) +func (fs *FSKeyStore) updateKeyRingWithCerts(o CertOption, hwks hardwarekey.Service, keyRing *KeyRing) error { + return o.updateKeyRing( + fs.KeyDir, + keyRing.KeyRingIndex, + keyRing, + keys.WithHardwareKeyService(hwks), + keys.WithContextualKeyInfo(keyRing.contextualKeyInfo()), + ) } // GetSSHCertificates gets all certificates signed for the given user and proxy. @@ -610,7 +613,7 @@ func (fs *FSKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Cer return sshCerts, nil } -func getCredentialsByName(credentialDir string, customPrompt keys.HardwareKeyPrompt) (map[string]TLSCredential, error) { +func getCredentialsByName(credentialDir string, opts ...keys.ParsePrivateKeyOpt) (map[string]TLSCredential, error) { files, err := os.ReadDir(credentialDir) if err != nil { return nil, trace.ConvertSystemError(err) @@ -620,7 +623,7 @@ func getCredentialsByName(credentialDir string, customPrompt keys.HardwareKeyPro if keyName := keypaths.TrimKeyPathSuffix(file.Name()); keyName != file.Name() { keyPath := filepath.Join(credentialDir, file.Name()) certPath := filepath.Join(credentialDir, keyName+keypaths.FileExtTLSCert) - cred, err := readTLSCredential(keyPath, certPath, customPrompt) + cred, err := readTLSCredential(keyPath, certPath, opts...) if err != nil { if trace.IsNotFound(err) { // Somehow we have a key with no cert, skip it. This should @@ -635,7 +638,7 @@ func getCredentialsByName(credentialDir string, customPrompt keys.HardwareKeyPro return credsByName, nil } -func getKubeCredentialsByName(credentialDir string) (map[string]TLSCredential, error) { +func getKubeCredentialsByName(credentialDir string, opts ...keys.ParsePrivateKeyOpt) (map[string]TLSCredential, error) { files, err := os.ReadDir(credentialDir) if err != nil { return nil, trace.ConvertSystemError(err) @@ -644,7 +647,7 @@ func getKubeCredentialsByName(credentialDir string) (map[string]TLSCredential, e for _, file := range files { if credName := strings.TrimSuffix(file.Name(), keypaths.FileExtKubeCred); credName != file.Name() { credPath := filepath.Join(credentialDir, file.Name()) - cred, err := readKubeCredential(credPath) + cred, err := readKubeCredential(credPath, opts...) if err != nil { return nil, trace.Wrap(err) } @@ -658,7 +661,7 @@ func getKubeCredentialsByName(credentialDir string) (map[string]TLSCredential, e type CertOption interface { // updateKeyRing is used by [FSKeyStore] to add the relevant credentials // loaded from disk to [keyRing]. - updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, customPrompt keys.HardwareKeyPrompt) error + updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, opts ...keys.ParsePrivateKeyOpt) error // pathsToDelete is used by [FSKeyStore] to get all the paths (files and/or // directories) that should be deleted by [DeleteUserCerts]. pathsToDelete(keyDir string, idx KeyRingIndex) []string @@ -672,7 +675,7 @@ var WithAllCerts = []CertOption{WithSSHCerts{}, WithKubeCerts{}, WithDBCerts{}, // WithSSHCerts is a CertOption for handling SSH certificates. type WithSSHCerts struct{} -func (o WithSSHCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, _ keys.HardwareKeyPrompt) error { +func (o WithSSHCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, _ ...keys.ParsePrivateKeyOpt) error { certPath := keypaths.SSHCertPath(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) cert, err := os.ReadFile(certPath) if err != nil { @@ -696,9 +699,9 @@ func (o WithSSHCerts) deleteFromKeyRing(keyRing *KeyRing) { // WithKubeCerts is a CertOption for handling kubernetes certificates. type WithKubeCerts struct{} -func (o WithKubeCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, _ keys.HardwareKeyPrompt) error { +func (o WithKubeCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, opts ...keys.ParsePrivateKeyOpt) error { credentialDir := keypaths.KubeCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) - credsByName, err := getKubeCredentialsByName(credentialDir) + credsByName, err := getKubeCredentialsByName(credentialDir, opts...) if err != nil { return trace.Wrap(err) } @@ -722,9 +725,9 @@ type WithDBCerts struct { dbName string } -func (o WithDBCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, customPrompt keys.HardwareKeyPrompt) error { +func (o WithDBCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, opts ...keys.ParsePrivateKeyOpt) error { credentialDir := keypaths.DatabaseCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) - credsByName, err := getCredentialsByName(credentialDir, customPrompt) + credsByName, err := getCredentialsByName(credentialDir, opts...) if err != nil { return trace.Wrap(err) } @@ -754,9 +757,9 @@ type WithAppCerts struct { appName string } -func (o WithAppCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, customPrompt keys.HardwareKeyPrompt) error { +func (o WithAppCerts) updateKeyRing(keyDir string, idx KeyRingIndex, keyRing *KeyRing, opts ...keys.ParsePrivateKeyOpt) error { credentialDir := keypaths.AppCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) - credsByName, err := getCredentialsByName(credentialDir, customPrompt) + credsByName, err := getCredentialsByName(credentialDir, opts...) if err != nil { return trace.Wrap(err) } @@ -820,7 +823,7 @@ func (ms *MemKeyStore) AddKeyRing(keyRing *KeyRing) error { } // GetKeyRing returns the user's key ring including the specified certs. -func (ms *MemKeyStore) GetKeyRing(idx KeyRingIndex, opts ...CertOption) (*KeyRing, error) { +func (ms *MemKeyStore) GetKeyRing(idx KeyRingIndex, _ hardwarekey.Service, opts ...CertOption) (*KeyRing, error) { if len(opts) > 0 { if err := idx.Check(); err != nil { return nil, trace.Wrap(err, "GetKeyRing with CertOptions requires a fully specified KeyRingIndex") @@ -922,7 +925,3 @@ func (ms *MemKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Ce return sshCerts, nil } - -// SetCustomHardwareKeyPrompt implements the KeyStore.SetCustomHardwareKeyPrompt interface. -// Does nothing. -func (ms *MemKeyStore) SetCustomHardwareKeyPrompt(_ keys.HardwareKeyPrompt) {} diff --git a/lib/client/keystore_test.go b/lib/client/keystore_test.go index b78820ace94ce..a336867be9355 100644 --- a/lib/client/keystore_test.go +++ b/lib/client/keystore_test.go @@ -67,7 +67,7 @@ func TestKeyStore(t *testing.T) { // check that the key exists in the store and is the same, // except the key's trusted certs should be empty, to be // filled in by a trusted certs store. - retrievedKeyRing, err := keyStore.GetKeyRing(idx, WithAllCerts...) + retrievedKeyRing, err := keyStore.GetKeyRing(idx, nil /*hwks*/, WithAllCerts...) require.NoError(t, err) keyRing.TrustedCerts = nil assertEqualKeyRings(t, keyRing, retrievedKeyRing) @@ -75,14 +75,14 @@ func TestKeyStore(t *testing.T) { // Delete just the db cred, reload & verify it's gone err = keyStore.DeleteUserCerts(idx, WithDBCerts{}) require.NoError(t, err) - retrievedKeyRing, err = keyStore.GetKeyRing(idx, WithSSHCerts{}, WithDBCerts{}) + retrievedKeyRing, err = keyStore.GetKeyRing(idx, nil /*hwks*/, WithSSHCerts{}, WithDBCerts{}) require.NoError(t, err) expectKeyRing := keyRing.Copy() expectKeyRing.DBTLSCredentials = make(map[string]TLSCredential) assertEqualKeyRings(t, expectKeyRing, retrievedKeyRing) // check for the key, now without cluster name - retrievedKeyRing, err = keyStore.GetKeyRing(KeyRingIndex{idx.ProxyHost, idx.Username, ""}) + retrievedKeyRing, err = keyStore.GetKeyRing(KeyRingIndex{idx.ProxyHost, idx.Username, ""}, nil /*hwks*/) require.NoError(t, err) expectKeyRing.ClusterName = "" expectKeyRing.Cert = nil @@ -93,7 +93,7 @@ func TestKeyStore(t *testing.T) { require.NoError(t, err) // check that the key doesn't exist in the store - retrievedKeyRing, err = keyStore.GetKeyRing(idx) + retrievedKeyRing, err = keyStore.GetKeyRing(idx, nil /*hwks*/) require.Error(t, err) require.True(t, trace.IsNotFound(err)) require.Nil(t, retrievedKeyRing) @@ -128,14 +128,14 @@ func TestListKeys(t *testing.T) { // read all bob keys: for i := 0; i < keyNum; i++ { - keyRing, err := keyStore.GetKeyRing(keys[i].KeyRingIndex, WithSSHCerts{}, WithDBCerts{}) + keyRing, err := keyStore.GetKeyRing(keys[i].KeyRingIndex, nil /*hwks*/, WithSSHCerts{}, WithDBCerts{}) require.NoError(t, err) keyRing.TrustedCerts = keys[i].TrustedCerts assertEqualKeyRings(t, &keys[i], keyRing) } // read sam's key and make sure it's the same: - skeyRing, err := keyStore.GetKeyRing(samIdx, WithSSHCerts{}) + skeyRing, err := keyStore.GetKeyRing(samIdx, nil /*hwks*/, WithSSHCerts{}) require.NoError(t, err) require.Equal(t, samKeyRing.Cert, skeyRing.Cert) require.Equal(t, samKeyRing.TLSCert, skeyRing.TLSCert) @@ -190,9 +190,9 @@ func TestDeleteAll(t *testing.T) { require.NoError(t, err) // check keys exist - _, err = keyStore.GetKeyRing(idxFoo) + _, err = keyStore.GetKeyRing(idxFoo, nil /*hwks*/) require.NoError(t, err) - _, err = keyStore.GetKeyRing(idxBar) + _, err = keyStore.GetKeyRing(idxBar, nil /*hwks*/) require.NoError(t, err) // delete all keys @@ -200,9 +200,9 @@ func TestDeleteAll(t *testing.T) { require.NoError(t, err) // verify keys are gone - _, err = keyStore.GetKeyRing(idxFoo) + _, err = keyStore.GetKeyRing(idxFoo, nil /*hwks*/) require.True(t, trace.IsNotFound(err)) - _, err = keyStore.GetKeyRing(idxBar) + _, err = keyStore.GetKeyRing(idxBar, nil /*hwks*/) require.Error(t, err) }) } @@ -225,7 +225,7 @@ func TestCheckKey(t *testing.T) { err = keyStore.AddKeyRing(keyRing) require.NoError(t, err) - _, err = keyStore.GetKeyRing(idx) + _, err = keyStore.GetKeyRing(idx, nil /*hwks*/) require.NoError(t, err) }) } @@ -254,7 +254,7 @@ func TestCheckKeyFIPS(t *testing.T) { require.NoError(t, err) // Should return trace.BadParameter error because only RSA keys are supported. - _, err = keyStore.GetKeyRing(idx) + _, err = keyStore.GetKeyRing(idx, nil /*hwks*/) require.True(t, trace.IsBadParameter(err)) }) } @@ -276,7 +276,7 @@ func TestAddKey_withoutSSHCert(t *testing.T) { require.ErrorIs(t, err, os.ErrNotExist) // check db creds - keyCopy, err := keyStore.GetKeyRing(idx, WithDBCerts{}) + keyCopy, err := keyStore.GetKeyRing(idx, nil /*hwks*/, WithDBCerts{}) require.NoError(t, err) require.Len(t, keyCopy.DBTLSCredentials, 1) } diff --git a/lib/client/profile.go b/lib/client/profile.go index 6d60f203981ef..f12f6bab71f2d 100644 --- a/lib/client/profile.go +++ b/lib/client/profile.go @@ -35,6 +35,8 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/wrappers" "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/tlsca" @@ -596,9 +598,9 @@ func (p *ProfileStatus) DatabasesForCluster(clusterName string) ([]tlsca.RouteTo Username: p.Username, ClusterName: clusterName, } - + hwks := piv.NewYubiKeyService(context.TODO(), &hardwarekey.CLIPrompt{}) store := NewFSKeyStore(p.Dir) - keyRing, err := store.GetKeyRing(idx, WithDBCerts{}) + keyRing, err := store.GetKeyRing(idx, hwks, WithDBCerts{}) if err != nil { return nil, trace.Wrap(err) } @@ -618,8 +620,9 @@ func (p *ProfileStatus) AppsForCluster(clusterName string) ([]tlsca.RouteToApp, ClusterName: clusterName, } + hwks := piv.NewYubiKeyService(context.TODO(), &hardwarekey.CLIPrompt{}) store := NewFSKeyStore(p.Dir) - keyRing, err := store.GetKeyRing(idx, WithAppCerts{}) + keyRing, err := store.GetKeyRing(idx, hwks, WithAppCerts{}) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index 0aa7f07ea6503..6640a48753e04 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -42,7 +42,7 @@ import ( "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/auth/authclient" wancli "github.com/gravitational/teleport/lib/auth/webauthncli" wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" @@ -257,13 +257,13 @@ type UserPublicKeys struct { // Deprecated: prefer SSHAttestationStatement and/or TLSAttestationStatement. // TODO(nklaassen): DELETE IN 18.0.0 when all clients should be using // separate keys. - AttestationStatement *keys.AttestationStatement `json:"attestation_statement,omitempty"` + AttestationStatement *hardwarekey.AttestationStatement `json:"attestation_statement,omitempty"` // SSHAttestationStatement is an attestation statement associated with the // given SSH public key. - SSHAttestationStatement *keys.AttestationStatement `json:"ssh_attestation_statement,omitempty"` + SSHAttestationStatement *hardwarekey.AttestationStatement `json:"ssh_attestation_statement,omitempty"` // TLSAttestationStatement is an attestation statement associated with the // given TLS public key. - TLSAttestationStatement *keys.AttestationStatement `json:"tls_attestation_statement,omitempty"` + TLSAttestationStatement *hardwarekey.AttestationStatement `json:"tls_attestation_statement,omitempty"` } // CheckAndSetDefaults checks and sets default values. @@ -312,13 +312,13 @@ type SSOUserPublicKeys struct { // AttestationStatement is an attestation statement associated with the given public key. // // Deprecated: prefer SSHAttestationStatement and/or TLSAttestationStatement. - AttestationStatement *keys.AttestationStatement `json:"attestation_statement,omitempty"` + AttestationStatement *hardwarekey.AttestationStatement `json:"attestation_statement,omitempty"` // SSHAttestationStatement is an attestation statement associated with the // given SSH public key. - SSHAttestationStatement *keys.AttestationStatement `json:"ssh_attestation_statement,omitempty"` + SSHAttestationStatement *hardwarekey.AttestationStatement `json:"ssh_attestation_statement,omitempty"` // TLSAttestationStatement is an attestation statement associated with the // given TLS public key. - TLSAttestationStatement *keys.AttestationStatement `json:"tls_attestation_statement,omitempty"` + TLSAttestationStatement *hardwarekey.AttestationStatement `json:"tls_attestation_statement,omitempty"` } // CheckAndSetDefaults checks and sets default values. @@ -415,9 +415,9 @@ type SSHLogin struct { // credentials to. KubernetesCluster string // SSHAttestationStatement is an attestation statement for SSHPubKey. - SSHAttestationStatement *keys.AttestationStatement + SSHAttestationStatement *hardwarekey.AttestationStatement // TLSAttestationStatement is an attestation statement for TLSPubKey. - TLSAttestationStatement *keys.AttestationStatement + TLSAttestationStatement *hardwarekey.AttestationStatement // ExtraHeaders is a map of extra HTTP headers to be included in requests. ExtraHeaders map[string]string } diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go index daa70d9f545c0..dce50510b900d 100644 --- a/lib/config/fileconf.go +++ b/lib/config/fileconf.go @@ -44,7 +44,7 @@ import ( "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" apiutils "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/tlsutils" "github.com/gravitational/teleport/lib/automaticupgrades" "github.com/gravitational/teleport/lib/backend" @@ -1044,7 +1044,7 @@ type AuthenticationConfig struct { DefaultSessionTTL types.Duration `yaml:"default_session_ttl"` // Deprecated. HardwareKey.PIVSlot should be used instead. - PIVSlot keys.PIVSlot `yaml:"piv_slot,omitempty"` + PIVSlot hardwarekey.PIVSlotKeyString `yaml:"piv_slot,omitempty"` // HardwareKey holds settings related to hardware key support. // Requires Teleport Enterprise. @@ -1277,7 +1277,7 @@ func (dt *DeviceTrust) Parse() (*types.DeviceTrust, error) { type HardwareKey struct { // PIVSlot is a PIV slot that Teleport clients should use instead of the // default based on private key policy. For example, "9a" or "9e". - PIVSlot keys.PIVSlot `yaml:"piv_slot,omitempty"` + PIVSlot hardwarekey.PIVSlotKeyString `yaml:"piv_slot,omitempty"` // SerialNumberValidation contains optional settings for hardware key // serial number validation, including whether it is enabled. diff --git a/lib/cryptosuites/suites.go b/lib/cryptosuites/suites.go index 8a06d18998c2c..353af008f028d 100644 --- a/lib/cryptosuites/suites.go +++ b/lib/cryptosuites/suites.go @@ -447,7 +447,7 @@ func GeneratePrivateKeyWithAlgorithm(alg Algorithm) (*keys.PrivateKey, error) { if err != nil { return nil, trace.Wrap(err) } - privateKey, err := keys.NewSoftwarePrivateKey(key) + privateKey, err := keys.NewPrivateKey(key) return privateKey, trace.Wrap(err) } diff --git a/lib/kube/kubeconfig/kubeconfig_test.go b/lib/kube/kubeconfig/kubeconfig_test.go index 0ee118d4ad090..7a100d496f57e 100644 --- a/lib/kube/kubeconfig/kubeconfig_test.go +++ b/lib/kube/kubeconfig/kubeconfig_test.go @@ -569,7 +569,7 @@ func genUserKeyRing(hostname string) (*client.KeyRing, []byte, error) { if err != nil { return nil, nil, trace.Wrap(err) } - priv, err := keys.NewSoftwarePrivateKey(key) + priv, err := keys.NewPrivateKey(key) if err != nil { return nil, nil, trace.Wrap(err) } diff --git a/lib/modules/modules.go b/lib/modules/modules.go index da15b036bd78c..0692c3b8fc00c 100644 --- a/lib/modules/modules.go +++ b/lib/modules/modules.go @@ -38,6 +38,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib/automaticupgrades" "github.com/gravitational/teleport/lib/tlsca" @@ -288,7 +289,7 @@ type Modules interface { // IsOSSBuild returns if the binary was built without enterprise modules IsOSSBuild() bool // AttestHardwareKey attests a hardware key and returns its associated private key policy. - AttestHardwareKey(context.Context, interface{}, *keys.AttestationStatement, crypto.PublicKey, time.Duration) (*keys.AttestationData, error) + AttestHardwareKey(context.Context, interface{}, *hardwarekey.AttestationStatement, crypto.PublicKey, time.Duration) (*keys.AttestationData, error) // GenerateAccessRequestPromotions generates a list of valid promotions for given access request. GenerateAccessRequestPromotions(context.Context, AccessResourcesGetter, types.AccessRequest) (*types.AccessRequestAllowedPromotions, error) // GetSuggestedAccessLists generates a list of valid promotions for given access request. @@ -427,7 +428,7 @@ func (p *defaultModules) IsBoringBinary() bool { } // AttestHardwareKey attests a hardware key. -func (p *defaultModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { +func (p *defaultModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *hardwarekey.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { // Default modules do not support attesting hardware keys. return nil, trace.NotFound("no attestation data for the given key") } diff --git a/lib/modules/test.go b/lib/modules/test.go index 82a4afd24d53f..efbe9c1e26684 100644 --- a/lib/modules/test.go +++ b/lib/modules/test.go @@ -27,6 +27,7 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" ) // TestModules implements the Modules interface for testing. @@ -104,7 +105,7 @@ func (m *TestModules) IsOSSBuild() bool { } // AttestHardwareKey attests a hardware key. -func (m *TestModules) AttestHardwareKey(ctx context.Context, obj interface{}, as *keys.AttestationStatement, pk crypto.PublicKey, d time.Duration) (*keys.AttestationData, error) { +func (m *TestModules) AttestHardwareKey(ctx context.Context, obj interface{}, as *hardwarekey.AttestationStatement, pk crypto.PublicKey, d time.Duration) (*keys.AttestationData, error) { if m.MockAttestationData != nil { return m.MockAttestationData, nil } diff --git a/lib/services/suite/suite.go b/lib/services/suite/suite.go index b12d4e5562e2a..004892d1ba712 100644 --- a/lib/services/suite/suite.go +++ b/lib/services/suite/suite.go @@ -122,11 +122,7 @@ func NewTestCAWithConfig(config TestCAConfig) *types.CertAuthorityV2 { if err != nil { panic(err) } - keyPEM, err = keys.MarshalPrivateKey(signer) - if err != nil { - panic(err) - } - key, err = keys.NewPrivateKey(signer, keyPEM) + key, err = keys.NewPrivateKey(signer) if err != nil { panic(err) } diff --git a/lib/srv/db/common/auth.go b/lib/srv/db/common/auth.go index 67510a8c42c17..e810b0648f26a 100644 --- a/lib/srv/db/common/auth.go +++ b/lib/srv/db/common/auth.go @@ -1074,7 +1074,7 @@ func (a *dbAuth) GenerateDatabaseClientKey(ctx context.Context) (*keys.PrivateKe if err != nil { return nil, trace.Wrap(err) } - privateKey, err := keys.NewSoftwarePrivateKey(signer) + privateKey, err := keys.NewPrivateKey(signer) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/tbot/service_ssh_host_output.go b/lib/tbot/service_ssh_host_output.go index 716ccc6b219a0..f0dfcfc023241 100644 --- a/lib/tbot/service_ssh_host_output.go +++ b/lib/tbot/service_ssh_host_output.go @@ -136,7 +136,7 @@ func (s *SSHHostOutputService) generate(ctx context.Context) error { if err != nil { return trace.Wrap(err) } - privKey, err := keys.NewSoftwarePrivateKey(key) + privKey, err := keys.NewPrivateKey(key) if err != nil { return trace.Wrap(err) } diff --git a/lib/teleterm/clusters/config.go b/lib/teleterm/clusters/config.go index ff94f4fdb533a..ed2613ad9dd20 100644 --- a/lib/teleterm/clusters/config.go +++ b/lib/teleterm/clusters/config.go @@ -25,9 +25,8 @@ import ( "github.com/jonboulle/clockwork" "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/client" - "github.com/gravitational/teleport/lib/teleterm/api/uri" ) // Config is the cluster service config @@ -45,9 +44,8 @@ type Config struct { WebauthnLogin client.WebauthnLoginFunc // AddKeysToAgent is passed to [client.Config]. AddKeysToAgent string - // CustomHardwareKeyPrompt is a custom hardware key prompt to use when asking - // for a hardware key PIN, touch, etc. - HardwareKeyPromptConstructor func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt + // HardwareKeyService is a service for interfacing with hardware keys. + HardwareKeyService hardwarekey.Service } // CheckAndSetDefaults checks the configuration for its validity and sets default values if needed @@ -56,8 +54,8 @@ func (c *Config) CheckAndSetDefaults() error { return trace.BadParameter("missing working directory") } - if c.HardwareKeyPromptConstructor == nil { - return trace.BadParameter("missing hardware key prompt constructor") + if c.HardwareKeyService == nil { + return trace.BadParameter("missing hardware key service") } if c.Clock == nil { diff --git a/lib/teleterm/clusters/storage.go b/lib/teleterm/clusters/storage.go index 7d5b1292aa25e..d6e9d04e24d3f 100644 --- a/lib/teleterm/clusters/storage.go +++ b/lib/teleterm/clusters/storage.go @@ -280,13 +280,12 @@ func (s *Storage) loadProfileStatusAndClusterKey(clusterClient *client.TeleportC func (s *Storage) makeDefaultClientConfig(rootClusterURI uri.ResourceURI) *client.Config { cfg := client.MakeDefaultConfig() - cfg.HomePath = s.Dir cfg.KeysDir = s.Dir cfg.InsecureSkipVerify = s.InsecureSkipVerify cfg.AddKeysToAgent = s.AddKeysToAgent cfg.WebauthnLogin = s.WebauthnLogin - cfg.CustomHardwareKeyPrompt = s.HardwareKeyPromptConstructor(rootClusterURI) + cfg.ClientStore = client.NewFSClientStore(s.Dir, s.HardwareKeyService) cfg.DTAuthnRunCeremony = dtauthn.NewCeremony().Run cfg.DTAutoEnroll = dtenroll.AutoEnroll return cfg diff --git a/lib/teleterm/daemon/daemon_test.go b/lib/teleterm/daemon/daemon_test.go index 2e7cd9a70d4de..078b8824036d1 100644 --- a/lib/teleterm/daemon/daemon_test.go +++ b/lib/teleterm/daemon/daemon_test.go @@ -40,7 +40,7 @@ import ( "google.golang.org/grpc/status" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/piv" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/clientcache" @@ -348,9 +348,7 @@ func TestUpdateTshdEventsServerAddress(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: homeDir, InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) @@ -385,9 +383,7 @@ func TestUpdateTshdEventsServerAddress_CredsErr(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: homeDir, InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(context.TODO(), nil /*prompt*/), }) require.NoError(t, err) @@ -489,9 +485,7 @@ func TestRetryWithRelogin(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) @@ -545,9 +539,7 @@ func TestConcurrentHeadlessAuthPrompts(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return nil - }, + HardwareKeyService: piv.NewYubiKeyService(ctx, nil /*prompt*/), }) require.NoError(t, err) diff --git a/lib/teleterm/daemon/hardwarekeyprompt.go b/lib/teleterm/daemon/hardwarekeyprompt.go index ad75ccf6f699b..202ed86c1318c 100644 --- a/lib/teleterm/daemon/hardwarekeyprompt.go +++ b/lib/teleterm/daemon/hardwarekeyprompt.go @@ -23,13 +23,11 @@ import ( "github.com/gravitational/trace" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" - "github.com/gravitational/teleport/lib/teleterm/api/uri" ) -// NewHardwareKeyPromptConstructor returns a new hardware key prompt constructor -// for this service and the given root cluster URI. +// NewHardwareKeyPrompt returns a new hardware key prompt. // // TODO(gzdunek): Improve multi-cluster and multi-hardware keys support. // The code in yubikey.go doesn't really support using multiple hardware keys (like one per cluster): @@ -46,19 +44,18 @@ import ( // Because the code in yubikey.go assumes you use a single key, we don't have any mutex here. // (unlike other modals triggered by tshd). // We don't expect receiving prompts from different hardware keys. -func (s *Service) NewHardwareKeyPromptConstructor(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return &hardwareKeyPrompter{s: s, rootClusterURI: rootClusterURI} +func (s *Service) NewHardwareKeyPrompt() hardwarekey.Prompt { + return &hardwareKeyPrompter{s: s} } type hardwareKeyPrompter struct { - s *Service - rootClusterURI uri.ResourceURI + s *Service } // Touch prompts the user to touch the hardware key. -func (h *hardwareKeyPrompter) Touch(ctx context.Context) error { +func (h *hardwareKeyPrompter) Touch(ctx context.Context, keyInfo hardwarekey.ContextualKeyInfo) error { _, err := h.s.tshdEventsClient.PromptHardwareKeyTouch(ctx, &api.PromptHardwareKeyTouchRequest{ - RootClusterUri: h.rootClusterURI.String(), + RootClusterUri: keyInfo.ProxyHost, }) if err != nil { return trace.Wrap(err) @@ -67,10 +64,10 @@ func (h *hardwareKeyPrompter) Touch(ctx context.Context) error { } // AskPIN prompts the user for a PIN. -func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement keys.PINPromptRequirement) (string, error) { +func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement hardwarekey.PINPromptRequirement, keyInfo hardwarekey.ContextualKeyInfo) (string, error) { res, err := h.s.tshdEventsClient.PromptHardwareKeyPIN(ctx, &api.PromptHardwareKeyPINRequest{ - RootClusterUri: h.rootClusterURI.String(), - PinOptional: requirement == keys.PINOptional, + RootClusterUri: keyInfo.ProxyHost, + PinOptional: requirement == hardwarekey.PINOptional, }) if err != nil { return "", trace.Wrap(err) @@ -81,14 +78,14 @@ func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement keys.PINPr // ChangePIN asks for a new PIN. // The Electron app prompt must handle default values for PIN and PUK, // preventing the user from submitting empty/default values. -func (h *hardwareKeyPrompter) ChangePIN(ctx context.Context) (*keys.PINAndPUK, error) { +func (h *hardwareKeyPrompter) ChangePIN(ctx context.Context, keyInfo hardwarekey.ContextualKeyInfo) (*hardwarekey.PINAndPUK, error) { res, err := h.s.tshdEventsClient.PromptHardwareKeyPINChange(ctx, &api.PromptHardwareKeyPINChangeRequest{ - RootClusterUri: h.rootClusterURI.String(), + RootClusterUri: keyInfo.ProxyHost, }) if err != nil { return nil, trace.Wrap(err) } - return &keys.PINAndPUK{ + return &hardwarekey.PINAndPUK{ PIN: res.Pin, PUK: res.Puk, PUKChanged: res.PukChanged, @@ -96,9 +93,9 @@ func (h *hardwareKeyPrompter) ChangePIN(ctx context.Context) (*keys.PINAndPUK, e } // ConfirmSlotOverwrite asks the user if the slot's private key and certificate can be overridden. -func (h *hardwareKeyPrompter) ConfirmSlotOverwrite(ctx context.Context, message string) (bool, error) { +func (h *hardwareKeyPrompter) ConfirmSlotOverwrite(ctx context.Context, message string, keyInfo hardwarekey.ContextualKeyInfo) (bool, error) { res, err := h.s.tshdEventsClient.ConfirmHardwareKeySlotOverwrite(ctx, &api.ConfirmHardwareKeySlotOverwriteRequest{ - RootClusterUri: h.rootClusterURI.String(), + RootClusterUri: keyInfo.ProxyHost, Message: message, }) if err != nil { diff --git a/lib/teleterm/gateway/kube.go b/lib/teleterm/gateway/kube.go index 87d1d4d4621ac..8827cb04fb85d 100644 --- a/lib/teleterm/gateway/kube.go +++ b/lib/teleterm/gateway/kube.go @@ -120,7 +120,7 @@ func newKubeCAKey(kubeCert tls.Certificate) (*keys.PrivateKey, error) { if err != nil { return nil, trace.Wrap(err) } - privateKey, err := keys.NewSoftwarePrivateKey(signer) + privateKey, err := keys.NewPrivateKey(signer) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/teleterm/teleterm.go b/lib/teleterm/teleterm.go index 0c6cde8efd031..75c7877e916bf 100644 --- a/lib/teleterm/teleterm.go +++ b/lib/teleterm/teleterm.go @@ -32,8 +32,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - "github.com/gravitational/teleport/api/utils/keys" - "github.com/gravitational/teleport/lib/teleterm/api/uri" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/lib/teleterm/apiserver" "github.com/gravitational/teleport/lib/teleterm/clusteridcache" "github.com/gravitational/teleport/lib/teleterm/clusters" @@ -42,7 +41,10 @@ import ( // Serve starts daemon service func Serve(ctx context.Context, cfg Config) error { - var hardwareKeyPromptConstructor func(clusterURI uri.ResourceURI) keys.HardwareKeyPrompt + // TODO(gzdunek): Move tshdEventsClient out of daemonService so that we can + // set the prompt before creating Storage. + hardwareKeyService := piv.NewYubiKeyService(ctx, nil /*prompt*/) + if err := cfg.CheckAndSetDefaults(); err != nil { return trace.Wrap(err) } @@ -59,9 +61,7 @@ func Serve(ctx context.Context, cfg Config) error { Clock: clock, InsecureSkipVerify: cfg.InsecureSkipVerify, AddKeysToAgent: cfg.AddKeysToAgent, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { - return hardwareKeyPromptConstructor(rootClusterURI) - }, + HardwareKeyService: hardwareKeyService, }) if err != nil { return trace.Wrap(err) @@ -81,9 +81,8 @@ func Serve(ctx context.Context, cfg Config) error { return trace.Wrap(err) } - // TODO(gzdunek): Move tshdEventsClient out of daemonService so that we can - // construct the prompt before creating Storage. - hardwareKeyPromptConstructor = daemonService.NewHardwareKeyPromptConstructor + hardwareKeyService.SetPrompt(daemonService.NewHardwareKeyPrompt()) + apiServer, err := apiserver.New(apiserver.Config{ HostAddr: cfg.Addr, InsecureSkipVerify: cfg.InsecureSkipVerify, diff --git a/lib/vnet/profile_osconfig_provider_darwin.go b/lib/vnet/profile_osconfig_provider_darwin.go index c83407a849579..38707d6551860 100644 --- a/lib/vnet/profile_osconfig_provider_darwin.go +++ b/lib/vnet/profile_osconfig_provider_darwin.go @@ -27,6 +27,8 @@ import ( "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/clientcache" "github.com/gravitational/teleport/lib/vnet/daemon" @@ -59,8 +61,10 @@ func newProfileOSConfigProvider(tunName, ipv6Prefix, dnsAddr, homePath string, d return nil, trace.Wrap(err) } + hwKeyService := piv.NewYubiKeyService(context.TODO(), &hardwarekey.CLIPrompt{}) + p := &profileOSConfigProvider{ - clientStore: client.NewFSClientStore(homePath), + clientStore: client.NewFSClientStore(homePath, hwKeyService), clusterConfigCache: NewClusterConfigCache(clockwork.NewRealClock()), daemonClientCred: daemonClientCred, tunName: tunName, diff --git a/tool/tctl/common/auth_command.go b/tool/tctl/common/auth_command.go index 6ee7f1f5a6450..834732f3cebb7 100644 --- a/tool/tctl/common/auth_command.go +++ b/tool/tctl/common/auth_command.go @@ -301,7 +301,7 @@ func (a *AuthCommand) GenerateKeys(ctx context.Context, clusterAPI authCommandCl if err != nil { return trace.Wrap(err) } - key, err := keys.NewSoftwarePrivateKey(signer) + key, err := keys.NewPrivateKey(signer) if err != nil { return trace.Wrap(err) } @@ -535,7 +535,7 @@ func (a *AuthCommand) generateHostKeys(ctx context.Context, clusterAPI certifica if err != nil { return trace.Wrap(err) } - key, err := keys.NewSoftwarePrivateKey(signer) + key, err := keys.NewPrivateKey(signer) if err != nil { return trace.Wrap(err) } @@ -895,7 +895,7 @@ func generateKeyRing(ctx context.Context, clusterAPI certificateSigner, purpose if err != nil { return nil, trace.Wrap(err) } - key, err := keys.NewSoftwarePrivateKey(signer) + key, err := keys.NewPrivateKey(signer) if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tctl/common/config/profile.go b/tool/tctl/common/config/profile.go index 6e5da64ba1f84..8491c7e8f57a7 100644 --- a/tool/tctl/common/config/profile.go +++ b/tool/tctl/common/config/profile.go @@ -29,6 +29,8 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/metadata" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/identityfile" @@ -40,15 +42,18 @@ import ( // LoadConfigFromProfile applies config from ~/.tsh/ profile if it's present func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authclient.Config, error) { ctx := context.TODO() + + hwKeyService := piv.NewYubiKeyService(context.TODO(), &hardwarekey.CLIPrompt{}) + proxyAddr := "" if len(ccf.AuthServerAddr) != 0 { proxyAddr = ccf.AuthServerAddr[0] } - clientStore := client.NewFSClientStore(cfg.TeleportHome) + clientStore := client.NewFSClientStore(cfg.TeleportHome, hwKeyService) if ccf.IdentityFilePath != "" { var err error - clientStore, err = identityfile.NewClientStoreFromIdentityFile(ccf.IdentityFilePath, proxyAddr, "") + clientStore, err = identityfile.NewClientStoreFromIdentityFile(ccf.IdentityFilePath, proxyAddr, "", hwKeyService) if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/teleport/testenv/test_server.go b/tool/teleport/testenv/test_server.go index 1e74cedda3275..b36f269856b5b 100644 --- a/tool/teleport/testenv/test_server.go +++ b/tool/teleport/testenv/test_server.go @@ -45,6 +45,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib" @@ -546,7 +547,7 @@ func (p *cliModules) IsBoringBinary() bool { } // AttestHardwareKey attests a hardware key. -func (p *cliModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { +func (p *cliModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *hardwarekey.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { return nil, trace.NotFound("no attestation data for the given key") } diff --git a/tool/tsh/common/db_test.go b/tool/tsh/common/db_test.go index 06787491de421..c05387235a9fc 100644 --- a/tool/tsh/common/db_test.go +++ b/tool/tsh/common/db_test.go @@ -459,7 +459,7 @@ func testDatabaseLogin(t *testing.T) { require.NoError(t, err) // Fetch the active profile. - clientStore := client.NewFSClientStore(tmpHomePath) + clientStore := client.NewFSClientStore(tmpHomePath, nil /*hwKeyService*/) profile, err := clientStore.ReadProfileStatus(s.root.Config.Proxy.WebAddr.String()) require.NoError(t, err) require.Equal(t, s.user.GetName(), profile.Username) diff --git a/tool/tsh/common/kube_proxy.go b/tool/tsh/common/kube_proxy.go index 3338851160e2d..f81a9128d2361 100644 --- a/tool/tsh/common/kube_proxy.go +++ b/tool/tsh/common/kube_proxy.go @@ -325,7 +325,7 @@ func makeKubeLocalProxy(cf *CLIConf, tc *client.TeleportClient, clusters kubecon if err != nil { return nil, trace.Wrap(err) } - localClientKey, err := keys.NewSoftwarePrivateKey(key) + localClientKey, err := keys.NewPrivateKey(key) if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/common/kubectl_test.go b/tool/tsh/common/kubectl_test.go index d55e679dae3a1..05ccd0c19fcfd 100644 --- a/tool/tsh/common/kubectl_test.go +++ b/tool/tsh/common/kubectl_test.go @@ -269,7 +269,7 @@ func mustSetupKubeconfig(t *testing.T, tshHome, kubeCluster string) string { kubeconfigLocation := filepath.Join(tshHome, "kubeconfig") key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256) require.NoError(t, err) - priv, err := keys.NewSoftwarePrivateKey(key) + priv, err := keys.NewPrivateKey(key) require.NoError(t, err) err = kubeconfig.Update(kubeconfigLocation, kubeconfig.Values{ TeleportClusterName: "localhost", diff --git a/tool/tsh/common/proxy_test.go b/tool/tsh/common/proxy_test.go index eeb10f32dc236..c83bcd288d26d 100644 --- a/tool/tsh/common/proxy_test.go +++ b/tool/tsh/common/proxy_test.go @@ -1505,7 +1505,7 @@ func TestProxyAppWithIdentity(t *testing.T) { key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256) require.NoError(t, err) - privateKey, err := keys.NewSoftwarePrivateKey(key) + privateKey, err := keys.NewPrivateKey(key) require.NoError(t, err) // Identity files only support a single key for SSH/TLS keyRing := client.NewKeyRing(privateKey, privateKey) diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index a3171c6bbdf56..c55f649377af7 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -67,7 +67,8 @@ import ( "github.com/gravitational/teleport/api/types/accesslist" apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/types/wrappers" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" "github.com/gravitational/teleport/api/utils/prompt" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth/authclient" @@ -4385,7 +4386,7 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err } if cf.PIVSlot != "" { - c.PIVSlot = keys.PIVSlot(cf.PIVSlot) + c.PIVSlot = hardwarekey.PIVSlotKeyString(cf.PIVSlot) if err = c.PIVSlot.Validate(); err != nil { return nil, trace.Wrap(err) } @@ -4575,10 +4576,12 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err } func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) { + hardwareKeyService := piv.NewYubiKeyService(cf.Context, &hardwarekey.CLIPrompt{}) + switch { case cf.IdentityFileIn != "": // Import identity file keys to in-memory client store. - clientStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName) + clientStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName, hardwareKeyService) if err != nil { return nil, trace.Wrap(err) } @@ -4587,16 +4590,16 @@ func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) { case cf.IdentityFileOut != "", cf.AuthConnector == constants.HeadlessConnector: // Store client keys in memory, where they can be exported to non-standard // FS formats (e.g. identity file) or used for a single client call in memory. - return client.NewMemClientStore(), nil + return client.NewMemClientStore(hardwareKeyService), nil case cf.AddKeysToAgent == client.AddKeysToAgentOnly: // Store client keys in memory, but save trusted certs and profile to disk. - clientStore := client.NewFSClientStore(cf.HomePath) + clientStore := client.NewFSClientStore(cf.HomePath, hardwareKeyService) clientStore.KeyStore = client.NewMemKeyStore() return clientStore, nil default: - return client.NewFSClientStore(cf.HomePath), nil + return client.NewFSClientStore(cf.HomePath, hardwareKeyService), nil } } diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go index 2db166e61f5d9..4f0932219a430 100644 --- a/tool/tsh/common/tsh_test.go +++ b/tool/tsh/common/tsh_test.go @@ -69,6 +69,7 @@ import ( apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/api/utils/prompt" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/integration/kube" @@ -266,7 +267,7 @@ func (p *cliModules) IsBoringBinary() bool { } // AttestHardwareKey attests a hardware key. -func (p *cliModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *keys.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { +func (p *cliModules) AttestHardwareKey(_ context.Context, _ interface{}, _ *hardwarekey.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) { return nil, trace.NotFound("no attestation data for the given key") } @@ -5822,9 +5823,9 @@ func TestLogout(t *testing.T) { return types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1, nil }) require.NoError(t, err) - sshPriv, err := keys.NewSoftwarePrivateKey(sshKey) + sshPriv, err := keys.NewPrivateKey(sshKey) require.NoError(t, err) - tlsPriv, err := keys.NewSoftwarePrivateKey(tlsKey) + tlsPriv, err := keys.NewPrivateKey(tlsKey) require.NoError(t, err) clientKeyRing := &client.KeyRing{ KeyRingIndex: client.KeyRingIndex{ @@ -5888,7 +5889,7 @@ func TestLogout(t *testing.T) { t.Parallel() tmpHomePath := t.TempDir() - store := client.NewFSClientStore(tmpHomePath) + store := client.NewFSClientStore(tmpHomePath, nil /*hwKeyService*/) err := store.AddKeyRing(clientKeyRing) require.NoError(t, err) store.SaveProfile(profile, true) diff --git a/tool/tsh/common/vnet_client_application.go b/tool/tsh/common/vnet_client_application.go index 7743edfc502c3..812c5a9b19829 100644 --- a/tool/tsh/common/vnet_client_application.go +++ b/tool/tsh/common/vnet_client_application.go @@ -28,6 +28,8 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/keys/piv" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/clientcache" @@ -45,7 +47,9 @@ type vnetClientApplication struct { } func newVnetClientApplication(cf *CLIConf) (*vnetClientApplication, error) { - clientStore := client.NewFSClientStore(cf.HomePath) + hwKeyService := piv.NewYubiKeyService(cf.Context, &hardwarekey.CLIPrompt{}) + + clientStore := client.NewFSClientStore(cf.HomePath, hwKeyService) p := &vnetClientApplication{ cf: cf,