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/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/alias.go b/api/utils/keys/alias.go new file mode 100644 index 0000000000000..e8e9863c6a733 --- /dev/null +++ b/api/utils/keys/alias.go @@ -0,0 +1,23 @@ +/* +Copyright 2025 Gravitational, Inc. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keys + +import "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + +// Temporary aliases for types moved to the hardwarekey or piv packages +// TODO(Joerger): Remove once /e no longer relies on them. + +type AttestationStatement = hardwarekey.AttestationStatement + +var AttestationStatementFromProto = hardwarekey.AttestationStatementFromProto diff --git a/api/utils/keys/cliprompt.go b/api/utils/keys/cliprompt.go deleted file mode 100644 index 7dce20d211a7c..0000000000000 --- a/api/utils/keys/cliprompt.go +++ /dev/null @@ -1,130 +0,0 @@ -//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 keys - -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{} - -func (c *cliPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement) (string, error) { - message := "Enter your YubiKey PIV PIN" - if requirement == PINOptional { - message = "Enter your YubiKey PIV PIN [blank to use default PIN]" - } - password, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), message) - return password, trace.Wrap(err) -} - -func (c *cliPrompt) Touch(_ context.Context) error { - _, err := fmt.Fprintln(os.Stderr, "Tap your YubiKey") - return trace.Wrap(err) -} - -func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { - var pinAndPUK = &PINAndPUK{} - for { - fmt.Fprintf(os.Stderr, "Please set a new 6-8 character PIN.\n") - newPIN, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), "Enter your new YubiKey PIV PIN") - if err != nil { - return nil, trace.Wrap(err) - } - newPINConfirm, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), "Confirm your new YubiKey PIV PIN") - if err != nil { - return nil, trace.Wrap(err) - } - - if newPIN != newPINConfirm { - fmt.Fprintf(os.Stderr, "PINs do not match.\n") - continue - } - - if newPIN == piv.DefaultPIN { - fmt.Fprintf(os.Stderr, "The default PIN %q is not supported.\n", piv.DefaultPIN) - continue - } - - if !isPINLengthValid(newPIN) { - fmt.Fprintf(os.Stderr, "PIN must be 6-8 characters long.\n") - continue - } - - pinAndPUK.PIN = newPIN - break - } - - puk, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), "Enter your YubiKey PIV PUK to reset PIN [blank to use default PUK]") - if err != nil { - return nil, trace.Wrap(err) - } - pinAndPUK.PUK = puk - - switch puk { - case piv.DefaultPUK: - fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", piv.DefaultPUK) - fallthrough - case "": - for { - fmt.Fprintf(os.Stderr, "Please set a new 6-8 character PUK (used to reset PIN).\n") - newPUK, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), "Enter your new YubiKey PIV PUK") - if err != nil { - return nil, trace.Wrap(err) - } - newPUKConfirm, err := prompt.Password(ctx, os.Stderr, prompt.Stdin(), "Confirm your new YubiKey PIV PUK") - if err != nil { - return nil, trace.Wrap(err) - } - - if newPUK != newPUKConfirm { - fmt.Fprintf(os.Stderr, "PUKs do not match.\n") - continue - } - - if newPUK == piv.DefaultPUK { - fmt.Fprintf(os.Stderr, "The default PUK %q is not supported.\n", piv.DefaultPUK) - continue - } - - if !isPINLengthValid(newPUK) { - fmt.Fprintf(os.Stderr, "PUK must be 6-8 characters long.\n") - continue - } - - pinAndPUK.PUK = newPUK - pinAndPUK.PUKChanged = true - break - } - } - return pinAndPUK, nil -} - -func (c *cliPrompt) ConfirmSlotOverwrite(ctx context.Context, message string) (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/attestation.go b/api/utils/keys/hardwarekey/attestation.go new file mode 100644 index 0000000000000..94de91e177dc0 --- /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 (a *AttestationStatement) ToProto() *attestationv1.AttestationStatement { + return (*attestationv1.AttestationStatement)(a) +} + +// AttestationStatementFromProto converts an AttestationStatement from its protobuf form. +func AttestationStatementFromProto(att *attestationv1.AttestationStatement) *AttestationStatement { + return (*AttestationStatement)(att) +} + +// MarshalJSON implements custom protobuf json marshaling. +func (a *AttestationStatement) MarshalJSON() ([]byte, error) { + buf := new(bytes.Buffer) + err := (&jsonpb.Marshaler{}).Marshal(buf, a.ToProto()) + return buf.Bytes(), trace.Wrap(err) +} + +// UnmarshalJSON implements custom protobuf json unmarshaling. +func (a *AttestationStatement) UnmarshalJSON(buf []byte) error { + return (&jsonpb.Unmarshaler{AllowUnknownFields: true}).Unmarshal(bytes.NewReader(buf), a.ToProto()) +} diff --git a/api/utils/keys/hardwarekey/cliprompt.go b/api/utils/keys/hardwarekey/cliprompt.go new file mode 100644 index 0000000000000..83ee48a030bc1 --- /dev/null +++ b/api/utils/keys/hardwarekey/cliprompt.go @@ -0,0 +1,153 @@ +// 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 hardwarekey + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils/prompt" +) + +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 { + writer io.Writer + reader prompt.StdinReader +} + +// NewStdCLIPrompt returns a new CLIPrompt with stderr and stdout. +func NewStdCLIPrompt() *cliPrompt { + return &cliPrompt{ + writer: os.Stderr, + reader: prompt.Stdin(), + } +} + +// NewStdCLIPrompt returns a new CLIPrompt with the given writer and reader. +// Used in tests. +func NewCLIPrompt(w io.Writer, r prompt.StdinReader) *cliPrompt { + return &cliPrompt{ + writer: w, + reader: r, + } +} + +func (c *cliPrompt) AskPIN(ctx context.Context, requirement PINPromptRequirement) (string, error) { + message := "Enter your YubiKey PIV PIN" + if requirement == PINOptional { + message = "Enter your YubiKey PIV PIN [blank to use default PIN]" + } + password, err := prompt.Password(ctx, c.writer, c.reader, message) + return password, trace.Wrap(err) +} + +func (c *cliPrompt) Touch(_ context.Context) error { + _, err := fmt.Fprintln(c.writer, "Tap your YubiKey") + return trace.Wrap(err) +} + +func (c *cliPrompt) ChangePIN(ctx context.Context) (*PINAndPUK, error) { + var pinAndPUK = &PINAndPUK{} + for { + fmt.Fprintf(c.writer, "Please set a new 6-8 character PIN.\n") + newPIN, err := prompt.Password(ctx, c.writer, c.reader, "Enter your new YubiKey PIV PIN") + if err != nil { + return nil, trace.Wrap(err) + } + newPINConfirm, err := prompt.Password(ctx, c.writer, c.reader, "Confirm your new YubiKey PIV PIN") + if err != nil { + return nil, trace.Wrap(err) + } + + if newPIN != newPINConfirm { + fmt.Fprintf(c.writer, "PINs do not match.\n") + continue + } + + if newPIN == DefaultPIN { + fmt.Fprintf(c.writer, "The default PIN %q is not supported.\n", DefaultPIN) + continue + } + + if !isPINLengthValid(newPIN) { + fmt.Fprintf(c.writer, "PIN must be 6-8 characters long.\n") + continue + } + + pinAndPUK.PIN = newPIN + break + } + + puk, err := prompt.Password(ctx, c.writer, c.reader, "Enter your YubiKey PIV PUK to reset PIN [blank to use default PUK]") + if err != nil { + return nil, trace.Wrap(err) + } + pinAndPUK.PUK = puk + + switch puk { + case DefaultPUK: + fmt.Fprintf(c.writer, "The default PUK %q is not supported.\n", DefaultPUK) + fallthrough + case "": + for { + fmt.Fprintf(c.writer, "Please set a new 6-8 character PUK (used to reset PIN).\n") + newPUK, err := prompt.Password(ctx, c.writer, c.reader, "Enter your new YubiKey PIV PUK") + if err != nil { + return nil, trace.Wrap(err) + } + newPUKConfirm, err := prompt.Password(ctx, c.writer, c.reader, "Confirm your new YubiKey PIV PUK") + if err != nil { + return nil, trace.Wrap(err) + } + + if newPUK != newPUKConfirm { + fmt.Fprintf(c.writer, "PUKs do not match.\n") + continue + } + + if newPUK == DefaultPUK { + fmt.Fprintf(c.writer, "The default PUK %q is not supported.\n", DefaultPUK) + continue + } + + if !isPINLengthValid(newPUK) { + fmt.Fprintf(c.writer, "PUK must be 6-8 characters long.\n") + continue + } + + pinAndPUK.PUK = newPUK + pinAndPUK.PUKChanged = true + break + } + } + return pinAndPUK, nil +} + +func (c *cliPrompt) ConfirmSlotOverwrite(ctx context.Context, message string) (bool, error) { + confirmation, err := prompt.Confirmation(ctx, c.writer, c.reader, message) + return confirmation, trace.Wrap(err) +} diff --git a/api/utils/keys/hardwarekey/cliprompt_test.go b/api/utils/keys/hardwarekey/cliprompt_test.go new file mode 100644 index 0000000000000..4c8351ac557c4 --- /dev/null +++ b/api/utils/keys/hardwarekey/cliprompt_test.go @@ -0,0 +1,161 @@ +// 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 ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/prompt" +) + +func TestChangePIN(t *testing.T) { + const validPINPUK = "1234567" + + for _, tc := range []struct { + name string + inputs []string + expectPINAndPUK *hardwarekey.PINAndPUK + expectOutput string + expectError error + }{ + { + name: "no input", + expectError: context.DeadlineExceeded, + }, + { + name: "set pin short", + inputs: []string{ + "123", // pin + "123", // confirm + }, + expectOutput: "PIN must be 6-8 characters long.", + expectError: context.DeadlineExceeded, + }, { + name: "set pin mismatch", + inputs: []string{ + "", // pin + hardwarekey.DefaultPIN, // confirm + }, + expectOutput: "PINs do not match.", + expectError: context.DeadlineExceeded, + }, { + name: "set pin default", + inputs: []string{ + hardwarekey.DefaultPIN, // pin + hardwarekey.DefaultPIN, // confirm + }, + expectOutput: fmt.Sprintf("The default PIN %q is not supported.", hardwarekey.DefaultPIN), + expectError: context.DeadlineExceeded, + }, { + name: "set puk short", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + "", // empty puk -> trigger set puk + "123", // puk + "123", // confirm + }, + expectOutput: "PUK must be 6-8 characters long.", + expectError: context.DeadlineExceeded, + }, { + name: "set puk mismatch", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + "", // empty puk -> trigger set puk + "", // puk + validPINPUK, // confirm + }, + expectOutput: "PUKs do not match.", + expectError: context.DeadlineExceeded, + }, { + name: "set puk default", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + "", // empty puk -> trigger set puk + hardwarekey.DefaultPUK, // puk + hardwarekey.DefaultPUK, // confirm + }, + expectOutput: fmt.Sprintf("The default PUK %q is not supported.", hardwarekey.DefaultPUK), + expectError: context.DeadlineExceeded, + }, { + name: "set puk from empty", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + "", // empty puk -> trigger set puk + validPINPUK, // puk + validPINPUK, // confirm + }, + expectPINAndPUK: &hardwarekey.PINAndPUK{ + PIN: validPINPUK, + PUK: validPINPUK, + PUKChanged: true, + }, + }, { + name: "set puk from default", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + hardwarekey.DefaultPUK, // default puk -> trigger set puk + validPINPUK, // puk + validPINPUK, // confirm + }, + expectPINAndPUK: &hardwarekey.PINAndPUK{ + PIN: validPINPUK, + PUK: validPINPUK, + PUKChanged: true, + }, + }, { + name: "valid pin, valid puk", + inputs: []string{ + validPINPUK, // pin + validPINPUK, // confirm + validPINPUK, // puk + }, + expectPINAndPUK: &hardwarekey.PINAndPUK{ + PIN: validPINPUK, + PUK: validPINPUK, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + promptWriter := bytes.NewBuffer([]byte{}) + promptReader := prompt.NewFakeReader() + prompt := hardwarekey.NewCLIPrompt(promptWriter, promptReader) + + for _, input := range tc.inputs { + promptReader.AddString(input) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + PINAndPUK, err := prompt.ChangePIN(ctx) + require.ErrorIs(t, err, tc.expectError) + require.Equal(t, tc.expectPINAndPUK, PINAndPUK) + }) + } +} diff --git a/api/utils/keys/hardwarekey/hardwarekey.go b/api/utils/keys/hardwarekey/hardwarekey.go new file mode 100644 index 0000000000000..bf18c9cb27e70 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey.go @@ -0,0 +1,214 @@ +// 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, 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) + // TODO(Joerger): DELETE IN v19.0.0 + // GetMissingKeyRefDetails updates the key ref with missing details from the hardware key. + // Used to fill in details from old client logins that only saved serial number and piv slot. + GetMissingKeyRefDetails(ref *PrivateKeyRef) error +} + +// PrivateKey is a hardware private key implementation of [crypto.Signer]. +type PrivateKey struct { + service Service + Ref *PrivateKeyRef +} + +// 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) *PrivateKey { + return &PrivateKey{ + service: s, + Ref: ref, + } +} + +// 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) (*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 := s.GetMissingKeyRefDetails(ref); err != nil { + return nil, trace.Wrap(err) + } + } + + return NewPrivateKey(s, ref), 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, 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 mocked PIV service, but we may extend support in + // the future as newer keys are being made with ed25519 support (YubiKey 5.7.x, SoloKey). + hash := sha512.Sum512(make([]byte, 512)) + _, err := h.service.Sign(ctx, h.Ref, 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 +} diff --git a/api/utils/keys/hardwarekey/hardwarekey_test.go b/api/utils/keys/hardwarekey/hardwarekey_test.go new file mode 100644 index 0000000000000..abb22c773cd17 --- /dev/null +++ b/api/utils/keys/hardwarekey/hardwarekey_test.go @@ -0,0 +1,163 @@ +// Copyright 2025 Gravitational, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package hardwarekey defines types and interfaces for hardware private keys. + +package hardwarekey_test + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/sha512" + "fmt" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/api/utils/prompt" +) + +// TestPrivateKey_EncodeDecode tests encoding and decoding a hardware 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 TestPrivateKey_EncodeDecode(t *testing.T) { + t.Parallel() + + ctx := context.Background() + s := hardwarekey.NewMockHardwareKeyService() + hwPriv, err := s.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{ + Policy: hardwarekey.PromptPolicyTouch, + }) + require.NoError(t, err) + + for _, tt := range []struct { + name string + ref *hardwarekey.PrivateKeyRef + updateKeyRef func(*hardwarekey.PrivateKeyRef) error + expectPriv *hardwarekey.PrivateKey + }{ + { + name: "new client encoding", + ref: hwPriv.Ref, + expectPriv: hwPriv, + }, + { + // 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: hwPriv.Ref.SerialNumber, + SlotKey: hwPriv.Ref.SlotKey, + }, + expectPriv: hwPriv, + }, + } { + t.Run(tt.name, func(t *testing.T) { + priv := hardwarekey.NewPrivateKey(s, tt.ref) + encoded, err := priv.Encode() + require.NoError(t, err) + + decodedPriv, err := hardwarekey.DecodePrivateKey(s, encoded) + require.NoError(t, err) + require.Equal(t, tt.expectPriv, decodedPriv) + }) + } +} + +// TestPrivateKey_Prompt tests hardware key service PIN/Touch logic with a mocked service. +func TestPrivateKey_Prompt(t *testing.T) { + t.Parallel() + + ctx := context.Background() + s := hardwarekey.NewMockHardwareKeyService() + + for _, policy := range []hardwarekey.PromptPolicy{ + hardwarekey.PromptPolicyNone, + hardwarekey.PromptPolicyTouch, + hardwarekey.PromptPolicyPIN, + hardwarekey.PromptPolicyTouchAndPIN, + } { + t.Run(fmt.Sprintf("policy:%+v", policy), func(t *testing.T) { + type newPrivateKeyRet struct { + priv *hardwarekey.PrivateKey + err error + } + + // Creating a new hardware key requires PIN/touch. + newPrivateKeyReturn := doWithPrompt(t, s, policy, func() newPrivateKeyRet { + hwPriv, err := s.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{ + Policy: policy, + }) + return newPrivateKeyRet{ + priv: hwPriv, + err: err, + } + }) + require.NoError(t, newPrivateKeyReturn.err) + hwPriv := newPrivateKeyReturn.priv + require.NotNil(t, hwPriv) + + // Signatures requires PIN/touch. Do a bogus signature. + err := doWithPrompt(t, s, policy, func() error { + hash := sha512.Sum512(make([]byte, 512)) + _, err := hwPriv.Sign(rand.Reader, hash[:], crypto.SHA512) + return err + }) + require.NoError(t, err) + }) + } +} + +func doWithPrompt[T any](t *testing.T, s *hardwarekey.MockHardwareKeyService, policy hardwarekey.PromptPolicy, fn func() T) T { + // Mock a CLI prompt. + pipeReader, pipeWriter := io.Pipe() + promptReader := prompt.NewFakeReader() + s.SetPrompt(hardwarekey.NewCLIPrompt(pipeWriter, promptReader)) + + out := make(chan T) + go func() { + out <- fn() + }() + + if policy.PINRequired { + out := make([]byte, 100) + _, err := pipeReader.Read(out) + assert.NoError(t, err) + assert.Contains(t, string(out), "Enter your YubiKey PIV PIN") + // mock service doesn't actually check the pin, it just waits for input. + promptReader.AddString("") + } + + if policy.TouchRequired { + out := make([]byte, 100) + _, err := pipeReader.Read(out) + assert.NoError(t, err) + assert.Contains(t, string(out), "Tap your YubiKey") + // mock touch. + s.MockTouch() + } + + select { + case out := <-out: + return out + case <-time.After(100 * time.Millisecond): + t.Error("failed to complete fn after prompts") + return *new(T) + } +} diff --git a/api/utils/keys/hardwarekey/prompt.go b/api/utils/keys/hardwarekey/prompt.go new file mode 100644 index 0000000000000..04781f7d6c4fa --- /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) (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 [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/service_mock.go b/api/utils/keys/hardwarekey/service_mock.go new file mode 100644 index 0000000000000..bcdead1a38e1f --- /dev/null +++ b/api/utils/keys/hardwarekey/service_mock.go @@ -0,0 +1,192 @@ +// Copyright 2025 Gravitational, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hardwarekey + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "io" + "sync" + "time" + + "github.com/gravitational/trace" +) + +// Currently Teleport does not provide a way to choose a specific hardware key, +// so we just hard code a serial number for all tests. +const serialNumber uint32 = 12345678 + +type fakeHardwarePrivateKey struct { + crypto.Signer + ref *PrivateKeyRef +} + +// hardwareKeySlot references a specific hardware key slot on a specific hardware key. +type hardwareKeySlot struct { + serialNumber uint32 + slot PIVSlotKey +} + +type MockHardwareKeyService struct { + prompt Prompt + mockTouch chan struct{} + + fakeHardwarePrivateKeys map[hardwareKeySlot]*fakeHardwarePrivateKey + fakeHardwarePrivateKeysMux *sync.Mutex +} + +// NewMockHardwareKeyService returns a [mockHardwareKeyService] for use in tests. +func NewMockHardwareKeyService() *MockHardwareKeyService { + return &MockHardwareKeyService{ + prompt: NewStdCLIPrompt(), + mockTouch: make(chan struct{}), + fakeHardwarePrivateKeys: map[hardwareKeySlot]*fakeHardwarePrivateKey{}, + fakeHardwarePrivateKeysMux: &sync.Mutex{}, + } +} + +func (s *MockHardwareKeyService) NewPrivateKey(ctx context.Context, config PrivateKeyConfig) (*PrivateKey, error) { + s.fakeHardwarePrivateKeysMux.Lock() + defer s.fakeHardwarePrivateKeysMux.Unlock() + + // Get the requested or default PIV slot. + var slotKey PIVSlotKey + var err error + if config.CustomSlot != "" { + slotKey, err = config.CustomSlot.Parse() + } else { + slotKey, err = GetDefaultSlotKey(config.Policy) + } + if err != nil { + return nil, trace.Wrap(err) + } + + keySlot := hardwareKeySlot{ + serialNumber: serialNumber, + slot: slotKey, + } + + if priv, ok := s.fakeHardwarePrivateKeys[keySlot]; ok { + return NewPrivateKey(s, priv.ref), nil + } + + // generating a new key with PIN/touch requirements requires the corresponding prompt. + if err := s.tryPrompt(ctx, config.Policy); err != nil { + return nil, trace.Wrap(err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, trace.Wrap(err) + } + + ref := &PrivateKeyRef{ + SerialNumber: serialNumber, + SlotKey: slotKey, + PublicKey: pub, + Policy: config.Policy, + // Since this is only used in tests, we will ignore the attestation statement in the end. + // We just need it to be non-nil so that it goes through the test modules implementation + // of Attest + AttestationStatement: &AttestationStatement{}, + } + + s.fakeHardwarePrivateKeys[keySlot] = &fakeHardwarePrivateKey{ + Signer: priv, + ref: ref, + } + + return NewPrivateKey(s, ref), nil +} + +// Sign performs a cryptographic signature using the specified hardware +// private key and provided signature parameters. +func (s *MockHardwareKeyService) Sign(ctx context.Context, ref *PrivateKeyRef, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + s.fakeHardwarePrivateKeysMux.Lock() + defer s.fakeHardwarePrivateKeysMux.Unlock() + + priv, ok := s.fakeHardwarePrivateKeys[hardwareKeySlot{ + serialNumber: serialNumber, + slot: ref.SlotKey, + }] + if !ok { + return nil, trace.NotFound("key not found in slot %d", ref.SlotKey) + } + + if err := s.tryPrompt(ctx, ref.Policy); err != nil { + return nil, trace.Wrap(err) + } + + return priv.Sign(rand, digest, opts) +} + +func (s *MockHardwareKeyService) tryPrompt(ctx context.Context, policy PromptPolicy) error { + if !policy.PINRequired && !policy.TouchRequired { + return nil + } + + if s.prompt == nil { + return trace.BadParameter("must provide a prompt to test a hardware key with a pin or touch policy") + } + + if policy.PINRequired { + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + if _, err := s.prompt.AskPIN(ctx, PINRequired); err != nil { + return trace.Wrap(err, "failed to handle pin prompt") + } + // We don't actually check the PIN for the current tests, any input is sufficient. + } + + if policy.TouchRequired { + if err := s.prompt.Touch(ctx); err != nil { + return trace.Wrap(err) + } + select { + case <-s.mockTouch: + case <-time.After(100 * time.Millisecond): + return trace.Wrap(context.DeadlineExceeded, "failed to handle touch prompt") + } + } + + return nil +} + +func (s *MockHardwareKeyService) SetPrompt(prompt Prompt) { + s.prompt = prompt +} + +// TODO(Joerger): DELETE IN v19.0.0 +func (s *MockHardwareKeyService) GetMissingKeyRefDetails(ref *PrivateKeyRef) error { + s.fakeHardwarePrivateKeysMux.Lock() + defer s.fakeHardwarePrivateKeysMux.Unlock() + + priv, ok := s.fakeHardwarePrivateKeys[hardwareKeySlot{ + serialNumber: serialNumber, + slot: ref.SlotKey, + }] + if !ok { + return trace.NotFound("key not found in slot %d", ref.SlotKey) + } + + *ref = *priv.ref + return nil +} + +func (s *MockHardwareKeyService) MockTouch() { + s.mockTouch <- struct{}{} +} 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 744733807470c..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.Unmarshaler{AllowUnknownFields: true}).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/piv/service.go b/api/utils/keys/piv/service.go new file mode 100644 index 0000000000000..d01df64f659b7 --- /dev/null +++ b/api/utils/keys/piv/service.go @@ -0,0 +1,335 @@ +//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" +) + +// TODO(Joerger): Rather than using a global cache and mutexes, clients should be updated +// to create a single YubiKeyService and ensure it is reused across the program execution. +var ( + // 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. + yubiKeys map[uint32]*YubiKey = map[uint32]*YubiKey{} + yubiKeysMux sync.Mutex + + // 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 +) + +// YubiKeyService is a YubiKey PIV implementation of [hardwarekey.Service]. +type YubiKeyService struct { + 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.NewStdCLIPrompt() + } + + 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); 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), 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)); 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); 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); 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, + }, + }, + }, + }), nil +} + +// Sign performs a cryptographic signature using the specified hardware +// private key and provided signature parameters. +func (s *YubiKeyService) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + // 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) + } + + promptMux.Lock() + defer promptMux.Unlock() + + return y.sign(ctx, ref, 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) { + promptMux.Lock() + defer promptMux.Unlock() + s.prompt = prompt +} + +// GetMissingKeyRefDetails 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 (s *YubiKeyService) GetMissingKeyRefDetails(ref *hardwarekey.PrivateKeyRef) error { + y, err := s.getYubiKey(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 nil +} + +// Get the given YubiKey with the serial number. If the provided serialNumber is "0", +// return the first YubiKey found in the smart card list. +func (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) error { + promptMux.Lock() + defer promptMux.Unlock() + + pin, err := s.prompt.AskPIN(ctx, hardwarekey.PINOptional) + 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) + if err != nil { + return trace.Wrap(err) + } + } + + return trace.Wrap(y.verifyPIN(pin)) +} + +func (s *YubiKeyService) promptOverwriteSlot(ctx context.Context, msg string) error { + promptMux.Lock() + defer 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); 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/service_fake.go b/api/utils/keys/piv/service_fake.go new file mode 100644 index 0000000000000..34918ab849462 --- /dev/null +++ b/api/utils/keys/piv/service_fake.go @@ -0,0 +1,33 @@ +//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" + + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" +) + +// TODO(Joerger): Rather than using a global service, clients should be updated to +// create a single YubiKeyService and ensure it is reused across the program +// execution. At this point, it may make more sense to directly inject the mocked +// hardware key service into the test instead of using the pivtest build tag to do it. +var yubikeyService hardwarekey.Service = hardwarekey.NewMockHardwareKeyService() + +func NewYubiKeyService(_ context.Context, _ hardwarekey.Prompt) hardwarekey.Service { + return yubikeyService +} diff --git a/api/utils/keys/yubikey_test.go b/api/utils/keys/piv/service_test.go similarity index 58% rename from api/utils/keys/yubikey_test.go rename to api/utils/keys/piv/service_test.go index 72ac01537041c..c62ddcd686e33 100644 --- a/api/utils/keys/yubikey_test.go +++ b/api/utils/keys/piv/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.NewStdCLIPrompt()) - 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, @@ -58,31 +70,42 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) { keys.PrivateKeyPolicyHardwareKeyTouchAndPIN, } { for _, customSlot := range []bool{true, false} { - t.Run(fmt.Sprintf("policy:%q", policy), func(t *testing.T) { + t.Run(fmt.Sprintf("policy:%+v", policy), func(t *testing.T) { t.Run(fmt.Sprintf("custom slot:%v", customSlot), func(t *testing.T) { resetYubikey(t, y) setupPINPrompt(t, y) - 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()) @@ -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.NewStdCLIPrompt()) - 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/service_unavailable.go b/api/utils/keys/piv/service_unavailable.go new file mode 100644 index 0000000000000..2e769a4e0b7f3 --- /dev/null +++ b/api/utils/keys/piv/service_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, _ 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 (s *unavailableYubiKeyPIVService) GetMissingKeyRefDetails(ref *hardwarekey.PrivateKeyRef) error { + return trace.Wrap(errPIVUnavailable) +} 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..c781f8e0574b4 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, 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) 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) 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() +// 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) - } - - attestation, err := piv.Verify(attCert, slotCert) - if err != nil { - return nil, trace.Wrap(err) + return nil, nil, nil, trace.Wrap(err) } - priv := &YubiKeyPrivateKey{ - YubiKey: y, - pivSlot: slot, - slotCert: slotCert, - attestationCert: attCert, - attestation: attestation, - } - - 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) (string, error) { + pinAndPUK, err := prompt.ChangePIN(ctx) if err != nil { - return trace.Wrap(err) + return "", trace.Wrap(err) + } + + if err := pinAndPUK.Validate(); 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 "": - if pin, err = y.setPINAndPUKFromDefault(ctx, y.prompt); 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) } } @@ -1019,12 +667,3 @@ 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 - } - return false -} 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/policy_piv.go b/api/utils/keys/policy_piv.go new file mode 100644 index 0000000000000..a99fc532f3010 --- /dev/null +++ b/api/utils/keys/policy_piv.go @@ -0,0 +1,45 @@ +//go:build piv && !pivtest + +/* +Copyright 2025 Gravitational, Inc. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keys + +import ( + "github.com/go-piv/piv-go/piv" +) + +// GetPrivateKeyPolicyFromAttestation returns the PrivateKeyPolicy satisfied by the given hardware key attestation. +// TODO(Joerger): Move to /e where this is used. +func GetPrivateKeyPolicyFromAttestation(att *piv.Attestation) PrivateKeyPolicy { + if att == nil { + return PrivateKeyPolicyNone + } + + 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 + } +} diff --git a/api/utils/keys/privatekey.go b/api/utils/keys/privatekey.go index 1255acb6f7df2..0d8a8a63b4465 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" ) @@ -91,6 +94,31 @@ func NewSoftwarePrivateKey(signer crypto.Signer) (*PrivateKey, error) { }, nil } +// 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") + } + + hwPrivateKey, err := s.NewPrivateKey(ctx, keyConfig) + if err != nil { + return nil, trace.Wrap(err) + } + + encodedKey, err := hwPrivateKey.Encode() + if err != nil { + return nil, trace.Wrap(err) + } + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: pivYubiKeyPrivateKeyType, + Bytes: encodedKey, + }) + + return NewPrivateKey(hwPrivateKey, privPEM) +} + // SSHPublicKey returns the ssh.PublicKey representation of the public key. func (k *PrivateKey) SSHPublicKey() ssh.PublicKey { return k.sshPub @@ -191,6 +219,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) @@ -210,14 +283,14 @@ 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 + CustomHardwareKeyPrompt hardwarekey.Prompt } // ParsePrivateKeyOpt applies configuration options. type ParsePrivateKeyOpt func(o *ParsePrivateKeyOptions) // WithCustomPrompt sets a custom hardware key prompt. -func WithCustomPrompt(prompt HardwareKeyPrompt) ParsePrivateKeyOpt { +func WithCustomPrompt(prompt hardwarekey.Prompt) ParsePrivateKeyOpt { return func(o *ParsePrivateKeyOptions) { o.CustomHardwareKeyPrompt = prompt } @@ -238,8 +311,16 @@ func ParsePrivateKey(keyPEM []byte, opts ...ParsePrivateKeyOpt) (*PrivateKey, er switch block.Type { case pivYubiKeyPrivateKeyType: - priv, err := parseYubiKeyPrivateKeyData(block.Bytes, appliedOpts.CustomHardwareKeyPrompt) - return priv, trace.Wrap(err, "parsing YubiKey private key") + // TODO(Joerger): Initialize the hardware key service early in the process and store + // it in the client store. This allows the process to properly share PIV connections + // and prompt logic (pin caching, etc.). + hwKeyService := piv.NewYubiKeyService(context.TODO(), appliedOpts.CustomHardwareKeyPrompt) + hwPrivateKey, err := hardwarekey.DecodePrivateKey(hwKeyService, block.Bytes) + if err != nil { + return nil, trace.Wrap(err, "failed to parse hardware private key") + } + + return NewPrivateKey(hwPrivateKey, keyPEM) case OpenSSHPrivateKeyType: priv, err := ssh.ParseRawPrivateKey(keyPEM) if err != nil { @@ -310,13 +391,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 +418,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 +426,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..f26aa65c199a4 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,28 @@ func TestMarshalAndParseKey(t *testing.T) { _, edKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) + // TODO(Joerger): Once the hardware key service is provided to the key parsing logic, + // use [hardwarekey.NewMockHardwareKeyService] and remove pivtest build tag + s := 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) 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 +80,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 +130,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 +156,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 +175,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 +186,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 +220,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 +280,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 +288,37 @@ func TestX509Certificate(t *testing.T) { tc.validateResult(t, cert) }) } +} + +// TestHardwareKeyMethods tests hardware key related methods with non-hardware keys. +// +// Testing these methods with actual hardware keys requires the piv go tag and should +// be tested individually in tests like `TestGetYubiKeyPrivateKey_Interactive`. +func TestHardwareKeyMethods(t *testing.T) { + ctx := context.Background() + + // Test hardware key methods with a software key. + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + key, err := keys.NewSoftwarePrivateKey(priv) + require.NoError(t, err) + + require.Nil(t, key.GetAttestationStatement()) + require.Equal(t, keys.PrivateKeyPolicyNone, key.GetPrivateKeyPolicy()) + require.False(t, key.IsHardware()) + require.NoError(t, key.WarmupHardwareKey(ctx)) + + // Test hardware key methods with a mocked hardware key. + s := hardwarekey.NewMockHardwareKeyService() + hwKey, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{ + Policy: hardwarekey.PromptPolicyTouch, + }) + require.NoError(t, err) + require.NotNil(t, hwKey.GetAttestationStatement()) + require.Equal(t, keys.PrivateKeyPolicyHardwareKeyTouch, hwKey.GetPrivateKeyPolicy()) + require.True(t, hwKey.IsHardware()) + require.NoError(t, hwKey.WarmupHardwareKey(ctx)) } var ( diff --git a/api/utils/keys/yubikey_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/integration/proxy/teleterm_test.go b/integration/proxy/teleterm_test.go index 915fa1208a0d9..0d3d622501e7d 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/hardwarekey" 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" @@ -248,7 +248,7 @@ func testGatewayCertRenewal(ctx context.Context, t *testing.T, params gatewayCer // db cert has expired. Clock: fakeClock, WebauthnLogin: webauthnLogin, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -882,7 +882,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) diff --git a/integration/teleterm_test.go b/integration/teleterm_test.go index baad6e2871e7e..0c376350d964c 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/hardwarekey" 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,7 +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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -293,7 +293,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -378,7 +378,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -433,7 +433,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -504,7 +504,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -766,7 +766,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -885,7 +885,7 @@ func testCreateConnectMyComputerToken(t *testing.T, pack *dbhelpers.DatabasePack InsecureSkipVerify: tc.InsecureSkipVerify, Clock: fakeClock, WebauthnLogin: webauthnLogin, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -948,7 +948,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -1035,7 +1035,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -1265,7 +1265,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 { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index d429a5108480a..47446073a6128 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" @@ -3399,9 +3399,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/github.go b/lib/auth/github.go index eac1519b6680a..96c23c7791948 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/client/api.go b/lib/client/api.go index af5dd6051800a..dc45361c420eb 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. @@ -497,7 +499,7 @@ type Config 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 keys.HardwareKeyPrompt + CustomHardwareKeyPrompt hardwarekey.Prompt // DisableSSHResumption disables transparent SSH connection resumption. DisableSSHResumption bool @@ -1285,9 +1287,10 @@ func NewClient(c *Config) (tc *TeleportClient, err error) { tc.ClientStore = NewMemClientStore() } else { tc.ClientStore = NewFSClientStore(c.KeysDir) - if c.CustomHardwareKeyPrompt != nil { + if tc.CustomHardwareKeyPrompt != nil { tc.ClientStore.SetCustomHardwareKeyPrompt(tc.CustomHardwareKeyPrompt) } + if c.AddKeysToAgent == AddKeysToAgentOnly { // Store client keys in memory, but still save trusted certs and profile to disk. tc.ClientStore.KeyStore = NewMemKeyStore() @@ -4002,7 +4005,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) + + // TODO(Joerger): Initialize the hardware key service early in the process and store + // it in the client store. This allows the process to properly share PIV connections + // and prompt logic (pin caching, etc.). + hwKeyService := piv.NewYubiKeyService(ctx, tc.CustomHardwareKeyPrompt) + priv, err := keys.NewHardwarePrivateKey(ctx, hwKeyService, hardwarekey.PrivateKeyConfig{ + CustomSlot: tc.PIVSlot, + Policy: hardwarekey.PromptPolicy{ + TouchRequired: tc.PrivateKeyPolicy.IsHardwareKeyTouchVerified(), + PINRequired: tc.PrivateKeyPolicy.IsHardwareKeyPINVerified(), + }, + }) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/client_store.go b/lib/client/client_store.go index 5762f9d7ec8ee..1500b9a48da8b 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" ) @@ -85,7 +86,7 @@ func (s *Store) AddKeyRing(keyRing *KeyRing) error { // SetCustomHardwareKeyPrompt sets a custom hardware key prompt // used to interact with a YubiKey private key. -func (s *Store) SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) { +func (s *Store) SetCustomHardwareKeyPrompt(prompt hardwarekey.Prompt) { s.KeyStore.SetCustomHardwareKeyPrompt(prompt) } diff --git a/lib/client/keystore.go b/lib/client/keystore.go index 43fa9b05c253e..6d794d7e6f393 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" ) @@ -91,7 +92,7 @@ type KeyStore interface { // SetCustomHardwareKeyPrompt sets a custom hardware key prompt // used to interact with a YubiKey private key. - SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) + SetCustomHardwareKeyPrompt(prompt hardwarekey.Prompt) } // FSKeyStore is an on-disk implementation of the KeyStore interface. @@ -106,7 +107,7 @@ type FSKeyStore struct { // 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 + CustomHardwareKeyPrompt hardwarekey.Prompt } // NewFSKeyStore initializes a new FSClientStore. @@ -195,7 +196,7 @@ func (fs *FSKeyStore) kubeCredPath(idx KeyRingIndex, kubename string) string { // SetCustomHardwareKeyPrompt sets a custom hardware key prompt // used to interact with a YubiKey private key. -func (fs *FSKeyStore) SetCustomHardwareKeyPrompt(prompt keys.HardwareKeyPrompt) { +func (fs *FSKeyStore) SetCustomHardwareKeyPrompt(prompt hardwarekey.Prompt) { fs.CustomHardwareKeyPrompt = prompt } @@ -301,7 +302,7 @@ 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, customPrompt hardwarekey.Prompt) (TLSCredential, error) { keyPEM, certPEM, err := readTLSCredentialFiles(keyPath, certPath) if err != nil { return TLSCredential{}, trace.Wrap(err) @@ -560,7 +561,7 @@ 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.WithCustomPrompt(fs.CustomHardwareKeyPrompt)) if err != nil { return nil, trace.ConvertSystemError(err) } @@ -610,7 +611,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, customPrompt hardwarekey.Prompt) (map[string]TLSCredential, error) { files, err := os.ReadDir(credentialDir) if err != nil { return nil, trace.ConvertSystemError(err) @@ -658,7 +659,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, customPrompt hardwarekey.Prompt) 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 +673,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, _ hardwarekey.Prompt) error { certPath := keypaths.SSHCertPath(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) cert, err := os.ReadFile(certPath) if err != nil { @@ -696,7 +697,7 @@ 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, _ hardwarekey.Prompt) error { credentialDir := keypaths.KubeCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) credsByName, err := getKubeCredentialsByName(credentialDir) if err != nil { @@ -722,7 +723,7 @@ 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, customPrompt hardwarekey.Prompt) error { credentialDir := keypaths.DatabaseCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) credsByName, err := getCredentialsByName(credentialDir, customPrompt) if err != nil { @@ -754,7 +755,7 @@ 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, customPrompt hardwarekey.Prompt) error { credentialDir := keypaths.AppCredentialDir(keyDir, idx.ProxyHost, idx.Username, idx.ClusterName) credsByName, err := getCredentialsByName(credentialDir, customPrompt) if err != nil { @@ -925,4 +926,4 @@ func (ms *MemKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Ce // SetCustomHardwareKeyPrompt implements the KeyStore.SetCustomHardwareKeyPrompt interface. // Does nothing. -func (ms *MemKeyStore) SetCustomHardwareKeyPrompt(_ keys.HardwareKeyPrompt) {} +func (ms *MemKeyStore) SetCustomHardwareKeyPrompt(_ hardwarekey.Prompt) {} diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go index 0348df9fe8896..c66791b07a87d 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/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/teleterm/clusters/config.go b/lib/teleterm/clusters/config.go index ff94f4fdb533a..6c5b5ee02e4e4 100644 --- a/lib/teleterm/clusters/config.go +++ b/lib/teleterm/clusters/config.go @@ -25,7 +25,7 @@ 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" ) @@ -47,7 +47,7 @@ type Config struct { 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 + HardwareKeyPromptConstructor func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt } // CheckAndSetDefaults checks the configuration for its validity and sets default values if needed diff --git a/lib/teleterm/clusters/storage.go b/lib/teleterm/clusters/storage.go index 7d5b1292aa25e..7389bfa9e6584 100644 --- a/lib/teleterm/clusters/storage.go +++ b/lib/teleterm/clusters/storage.go @@ -280,12 +280,15 @@ 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 + // TODO(Joerger): Remove the rootClusterURI dependency from the teleterm prompt so that + // the storage service can share a single hardware key service+prompt. This allows the + // process to properly share PIV connections, prevents duplicate prompts, and enables + // PIN caching across clusters (if both clusters allow PIN caching). cfg.CustomHardwareKeyPrompt = s.HardwareKeyPromptConstructor(rootClusterURI) cfg.DTAuthnRunCeremony = dtauthn.NewCeremony().Run cfg.DTAutoEnroll = dtenroll.AutoEnroll diff --git a/lib/teleterm/daemon/daemon_test.go b/lib/teleterm/daemon/daemon_test.go index 2e7cd9a70d4de..e9f9daffdc728 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/hardwarekey" 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,7 +348,7 @@ func TestUpdateTshdEventsServerAddress(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: homeDir, InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -385,7 +385,7 @@ func TestUpdateTshdEventsServerAddress_CredsErr(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: homeDir, InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -489,7 +489,7 @@ func TestRetryWithRelogin(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) @@ -545,7 +545,7 @@ func TestConcurrentHeadlessAuthPrompts(t *testing.T) { storage, err := clusters.NewStorage(clusters.Config{ Dir: t.TempDir(), InsecureSkipVerify: true, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return nil }, }) diff --git a/lib/teleterm/daemon/hardwarekeyprompt.go b/lib/teleterm/daemon/hardwarekeyprompt.go index ad75ccf6f699b..6a70b25f7659e 100644 --- a/lib/teleterm/daemon/hardwarekeyprompt.go +++ b/lib/teleterm/daemon/hardwarekeyprompt.go @@ -23,7 +23,7 @@ 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" ) @@ -46,7 +46,7 @@ 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 { +func (s *Service) NewHardwareKeyPromptConstructor(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return &hardwareKeyPrompter{s: s, rootClusterURI: rootClusterURI} } @@ -67,10 +67,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) (string, error) { res, err := h.s.tshdEventsClient.PromptHardwareKeyPIN(ctx, &api.PromptHardwareKeyPINRequest{ RootClusterUri: h.rootClusterURI.String(), - PinOptional: requirement == keys.PINOptional, + PinOptional: requirement == hardwarekey.PINOptional, }) if err != nil { return "", trace.Wrap(err) @@ -81,14 +81,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) (*hardwarekey.PINAndPUK, error) { res, err := h.s.tshdEventsClient.PromptHardwareKeyPINChange(ctx, &api.PromptHardwareKeyPINChangeRequest{ RootClusterUri: h.rootClusterURI.String(), }) if err != nil { return nil, trace.Wrap(err) } - return &keys.PINAndPUK{ + return &hardwarekey.PINAndPUK{ PIN: res.Pin, PUK: res.Puk, PUKChanged: res.PukChanged, diff --git a/lib/teleterm/teleterm.go b/lib/teleterm/teleterm.go index 0c6cde8efd031..8c6b2a897f3e5 100644 --- a/lib/teleterm/teleterm.go +++ b/lib/teleterm/teleterm.go @@ -32,7 +32,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/lib/teleterm/api/uri" "github.com/gravitational/teleport/lib/teleterm/apiserver" "github.com/gravitational/teleport/lib/teleterm/clusteridcache" @@ -42,7 +42,7 @@ import ( // Serve starts daemon service func Serve(ctx context.Context, cfg Config) error { - var hardwareKeyPromptConstructor func(clusterURI uri.ResourceURI) keys.HardwareKeyPrompt + var hardwareKeyPromptConstructor func(clusterURI uri.ResourceURI) hardwarekey.Prompt if err := cfg.CheckAndSetDefaults(); err != nil { return trace.Wrap(err) } @@ -59,7 +59,7 @@ func Serve(ctx context.Context, cfg Config) error { Clock: clock, InsecureSkipVerify: cfg.InsecureSkipVerify, AddKeysToAgent: cfg.AddKeysToAgent, - HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt { + HardwareKeyPromptConstructor: func(rootClusterURI uri.ResourceURI) hardwarekey.Prompt { return hardwareKeyPromptConstructor(rootClusterURI) }, }) diff --git a/tool/tsh/common/hardware_key_test.go b/tool/tsh/common/hardware_key_test.go index 8b83f293b94cd..69b8fc78aff5a 100644 --- a/tool/tsh/common/hardware_key_test.go +++ b/tool/tsh/common/hardware_key_test.go @@ -23,7 +23,6 @@ package common import ( "bytes" "context" - "crypto/tls" "encoding/json" "fmt" "net/http" @@ -384,7 +383,7 @@ func TestHardwareKeyApp(t *testing.T) { var info appConfigInfo require.NoError(t, json.Unmarshal(confOut.Bytes(), &info)) - clientCert, err := tls.LoadX509KeyPair(info.Cert, info.Key) + clientCert, err := keys.LoadX509KeyPair(info.Cert, info.Key) require.NoError(t, err) resp, err := testDummyAppConn(fmt.Sprintf("https://%v", proxyAddr.Addr), clientCert) @@ -461,7 +460,7 @@ func TestHardwareKeyApp(t *testing.T) { require.NoError(t, json.Unmarshal(confOut.Bytes(), &info)) - clientCert, err = tls.LoadX509KeyPair(info.Cert, info.Key) + clientCert, err = keys.LoadX509KeyPair(info.Cert, info.Key) require.NoError(t, err) resp, err = testDummyAppConn(fmt.Sprintf("https://%v", proxyAddr.Addr), clientCert) diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 84f17512a45b2..9522b6fc81ee6 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -67,7 +67,7 @@ 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/prompt" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth/authclient" @@ -4385,7 +4385,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) }