diff --git a/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go
new file mode 100644
index 0000000000000..dadf5268e106c
--- /dev/null
+++ b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.pb.go
@@ -0,0 +1,646 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.6
+// protoc (unknown)
+// source: teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto
+
+package hardwarekeyagentv1
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// PIVSlotKey is the key reference for a specific PIV slot.
+type PIVSlotKey int32
+
+const (
+ // PIV slot key not specified.
+ PIVSlotKey_PIV_SLOT_KEY_UNSPECIFIED PIVSlotKey = 0
+ // PIV slot key 9a. This is the default slot for pin_policy=never, touch_policy=never.
+ PIVSlotKey_PIV_SLOT_KEY_9A PIVSlotKey = 1
+ // PIV slot key 9c. This is the default slot for pin_policy=never, touch_policy=cached.
+ PIVSlotKey_PIV_SLOT_KEY_9C PIVSlotKey = 2
+ // PIV slot key 9d. This is the default slot for pin_policy=once, touch_policy=cached.
+ PIVSlotKey_PIV_SLOT_KEY_9D PIVSlotKey = 3
+ // PIV slot key 9e. This is the default slot for pin_policy=once, touch_policy=never.
+ PIVSlotKey_PIV_SLOT_KEY_9E PIVSlotKey = 4
+)
+
+// Enum value maps for PIVSlotKey.
+var (
+ PIVSlotKey_name = map[int32]string{
+ 0: "PIV_SLOT_KEY_UNSPECIFIED",
+ 1: "PIV_SLOT_KEY_9A",
+ 2: "PIV_SLOT_KEY_9C",
+ 3: "PIV_SLOT_KEY_9D",
+ 4: "PIV_SLOT_KEY_9E",
+ }
+ PIVSlotKey_value = map[string]int32{
+ "PIV_SLOT_KEY_UNSPECIFIED": 0,
+ "PIV_SLOT_KEY_9A": 1,
+ "PIV_SLOT_KEY_9C": 2,
+ "PIV_SLOT_KEY_9D": 3,
+ "PIV_SLOT_KEY_9E": 4,
+ }
+)
+
+func (x PIVSlotKey) Enum() *PIVSlotKey {
+ p := new(PIVSlotKey)
+ *p = x
+ return p
+}
+
+func (x PIVSlotKey) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (PIVSlotKey) Descriptor() protoreflect.EnumDescriptor {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes[0].Descriptor()
+}
+
+func (PIVSlotKey) Type() protoreflect.EnumType {
+ return &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes[0]
+}
+
+func (x PIVSlotKey) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use PIVSlotKey.Descriptor instead.
+func (PIVSlotKey) EnumDescriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{0}
+}
+
+// Hash refers to a specific hash function used during signing.
+type Hash int32
+
+const (
+ Hash_HASH_UNSPECIFIED Hash = 0
+ Hash_HASH_NONE Hash = 1
+ Hash_HASH_SHA256 Hash = 2
+ Hash_HASH_SHA512 Hash = 3
+)
+
+// Enum value maps for Hash.
+var (
+ Hash_name = map[int32]string{
+ 0: "HASH_UNSPECIFIED",
+ 1: "HASH_NONE",
+ 2: "HASH_SHA256",
+ 3: "HASH_SHA512",
+ }
+ Hash_value = map[string]int32{
+ "HASH_UNSPECIFIED": 0,
+ "HASH_NONE": 1,
+ "HASH_SHA256": 2,
+ "HASH_SHA512": 3,
+ }
+)
+
+func (x Hash) Enum() *Hash {
+ p := new(Hash)
+ *p = x
+ return p
+}
+
+func (x Hash) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Hash) Descriptor() protoreflect.EnumDescriptor {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes[1].Descriptor()
+}
+
+func (Hash) Type() protoreflect.EnumType {
+ return &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes[1]
+}
+
+func (x Hash) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Hash.Descriptor instead.
+func (Hash) EnumDescriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{1}
+}
+
+// PingRequest is a request to Ping.
+type PingRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PingRequest) Reset() {
+ *x = PingRequest{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PingRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PingRequest) ProtoMessage() {}
+
+func (x *PingRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead.
+func (*PingRequest) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{0}
+}
+
+// PingResponse is a response to Ping.
+type PingResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // PID is the PID of the client process running the agent.
+ Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PingResponse) Reset() {
+ *x = PingResponse{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PingResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PingResponse) ProtoMessage() {}
+
+func (x *PingResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead.
+func (*PingResponse) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *PingResponse) GetPid() uint32 {
+ if x != nil {
+ return x.Pid
+ }
+ return 0
+}
+
+// SignRequest is a request to perform a signature with a specific hardware private key.
+type SignRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Digest is a hashed message to sign.
+ Digest []byte `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"`
+ // Hash is the hash function used to prepare the digest.
+ Hash Hash `protobuf:"varint,2,opt,name=hash,proto3,enum=teleport.hardwarekeyagent.v1.Hash" json:"hash,omitempty"`
+ // SaltLength specifies the length of the salt added to the digest before a signature.
+ // This salt length is precomputed by the client, following the crypto/rsa implementation.
+ // Only used, and required, for PSS RSA signatures.
+ SaltLength uint32 `protobuf:"varint,3,opt,name=salt_length,json=saltLength,proto3" json:"salt_length,omitempty"`
+ // KeyRef references a specific hardware private key.
+ KeyRef *KeyRef `protobuf:"bytes,4,opt,name=key_ref,json=keyRef,proto3" json:"key_ref,omitempty"`
+ // KeyInfo contains additional, optional key info which generally will improve UX by
+ // giving the agent context about the key, such as whether PIN/touch prompts are
+ // expected, or what cluster login is trying to interface with the key.
+ KeyInfo *KeyInfo `protobuf:"bytes,5,opt,name=key_info,json=keyInfo,proto3" json:"key_info,omitempty"`
+ // Command is the client command or action requiring a signature, e.g. "tsh ssh server01".
+ // The agent can include this detail in PIN/touch prompts to show the origin of the
+ // signature request to the user.
+ Command string `protobuf:"bytes,6,opt,name=command,proto3" json:"command,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SignRequest) Reset() {
+ *x = SignRequest{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SignRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SignRequest) ProtoMessage() {}
+
+func (x *SignRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SignRequest.ProtoReflect.Descriptor instead.
+func (*SignRequest) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *SignRequest) GetDigest() []byte {
+ if x != nil {
+ return x.Digest
+ }
+ return nil
+}
+
+func (x *SignRequest) GetHash() Hash {
+ if x != nil {
+ return x.Hash
+ }
+ return Hash_HASH_UNSPECIFIED
+}
+
+func (x *SignRequest) GetSaltLength() uint32 {
+ if x != nil {
+ return x.SaltLength
+ }
+ return 0
+}
+
+func (x *SignRequest) GetKeyRef() *KeyRef {
+ if x != nil {
+ return x.KeyRef
+ }
+ return nil
+}
+
+func (x *SignRequest) GetKeyInfo() *KeyInfo {
+ if x != nil {
+ return x.KeyInfo
+ }
+ return nil
+}
+
+func (x *SignRequest) GetCommand() string {
+ if x != nil {
+ return x.Command
+ }
+ return ""
+}
+
+// Signature is a private key signature.
+type Signature struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // For an RSA key, signature should be either a PKCS #1 v1.5 or PSS signature,
+ // depending on the hash and salt chosen. For an (EC)DSA key, it should be a
+ // DER-serialised, ASN.1 signature structure.
+ Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Signature) Reset() {
+ *x = Signature{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Signature) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Signature) ProtoMessage() {}
+
+func (x *Signature) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Signature.ProtoReflect.Descriptor instead.
+func (*Signature) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *Signature) GetSignature() []byte {
+ if x != nil {
+ return x.Signature
+ }
+ return nil
+}
+
+// KeyRef references a specific hardware private key.
+type KeyRef struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // SerialNumber is the serial number of the hardware key.
+ SerialNumber uint32 `protobuf:"varint,1,opt,name=serial_number,json=serialNumber,proto3" json:"serial_number,omitempty"`
+ // SlotKey is a PIV slot key reference.
+ SlotKey PIVSlotKey `protobuf:"varint,2,opt,name=slot_key,json=slotKey,proto3,enum=teleport.hardwarekeyagent.v1.PIVSlotKey" json:"slot_key,omitempty"`
+ // PublicKey is the public key encoded in PKIX, ASN.1 DER form. If the public key does
+ // not match the private key currently in the hardware key's PIV slot, the signature
+ // will fail early.
+ PublicKeyDer []byte `protobuf:"bytes,3,opt,name=public_key_der,json=publicKeyDer,proto3" json:"public_key_der,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *KeyRef) Reset() {
+ *x = KeyRef{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *KeyRef) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*KeyRef) ProtoMessage() {}
+
+func (x *KeyRef) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use KeyRef.ProtoReflect.Descriptor instead.
+func (*KeyRef) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *KeyRef) GetSerialNumber() uint32 {
+ if x != nil {
+ return x.SerialNumber
+ }
+ return 0
+}
+
+func (x *KeyRef) GetSlotKey() PIVSlotKey {
+ if x != nil {
+ return x.SlotKey
+ }
+ return PIVSlotKey_PIV_SLOT_KEY_UNSPECIFIED
+}
+
+func (x *KeyRef) GetPublicKeyDer() []byte {
+ if x != nil {
+ return x.PublicKeyDer
+ }
+ return nil
+}
+
+// KeyInfo contains additional information about a hardware private key.
+type KeyInfo struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // TouchRequired is a client hint as to whether the hardware private key requires touch.
+ // The agent will use this to provide the ideal UX for the touch prompt. If this client
+ // hint is incorrect, touch will still be prompted.
+ TouchRequired bool `protobuf:"varint,1,opt,name=touch_required,json=touchRequired,proto3" json:"touch_required,omitempty"`
+ // PinRequired is a client hint as to whether the hardware private key requires PIN.
+ // The agent will use this to provide the ideal UX for the PIN prompt. If this client
+ // hint is incorrect, PIN will still be prompted for YubiKey versions >= 4.3.0, and
+ // failing with an auth error otherwise.
+ PinRequired bool `protobuf:"varint,2,opt,name=pin_required,json=pinRequired,proto3" json:"pin_required,omitempty"`
+ // ProxyHost is a Teleport proxy hostname that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ ProxyHost string `protobuf:"bytes,3,opt,name=proxy_host,json=proxyHost,proto3" json:"proxy_host,omitempty"`
+ // Username is a Teleport username that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"`
+ // ClusterName is a Teleport cluster name that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ ClusterName string `protobuf:"bytes,5,opt,name=cluster_name,json=clusterName,proto3" json:"cluster_name,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *KeyInfo) Reset() {
+ *x = KeyInfo{}
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *KeyInfo) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*KeyInfo) ProtoMessage() {}
+
+func (x *KeyInfo) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use KeyInfo.ProtoReflect.Descriptor instead.
+func (*KeyInfo) Descriptor() ([]byte, []int) {
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *KeyInfo) GetTouchRequired() bool {
+ if x != nil {
+ return x.TouchRequired
+ }
+ return false
+}
+
+func (x *KeyInfo) GetPinRequired() bool {
+ if x != nil {
+ return x.PinRequired
+ }
+ return false
+}
+
+func (x *KeyInfo) GetProxyHost() string {
+ if x != nil {
+ return x.ProxyHost
+ }
+ return ""
+}
+
+func (x *KeyInfo) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *KeyInfo) GetClusterName() string {
+ if x != nil {
+ return x.ClusterName
+ }
+ return ""
+}
+
+var File_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto protoreflect.FileDescriptor
+
+const file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc = "" +
+ "\n" +
+ ";teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto\x12\x1cteleport.hardwarekeyagent.v1\"\r\n" +
+ "\vPingRequest\" \n" +
+ "\fPingResponse\x12\x10\n" +
+ "\x03pid\x18\x01 \x01(\rR\x03pid\"\x99\x02\n" +
+ "\vSignRequest\x12\x16\n" +
+ "\x06digest\x18\x01 \x01(\fR\x06digest\x126\n" +
+ "\x04hash\x18\x02 \x01(\x0e2\".teleport.hardwarekeyagent.v1.HashR\x04hash\x12\x1f\n" +
+ "\vsalt_length\x18\x03 \x01(\rR\n" +
+ "saltLength\x12=\n" +
+ "\akey_ref\x18\x04 \x01(\v2$.teleport.hardwarekeyagent.v1.KeyRefR\x06keyRef\x12@\n" +
+ "\bkey_info\x18\x05 \x01(\v2%.teleport.hardwarekeyagent.v1.KeyInfoR\akeyInfo\x12\x18\n" +
+ "\acommand\x18\x06 \x01(\tR\acommand\")\n" +
+ "\tSignature\x12\x1c\n" +
+ "\tsignature\x18\x01 \x01(\fR\tsignature\"\x98\x01\n" +
+ "\x06KeyRef\x12#\n" +
+ "\rserial_number\x18\x01 \x01(\rR\fserialNumber\x12C\n" +
+ "\bslot_key\x18\x02 \x01(\x0e2(.teleport.hardwarekeyagent.v1.PIVSlotKeyR\aslotKey\x12$\n" +
+ "\x0epublic_key_der\x18\x03 \x01(\fR\fpublicKeyDer\"\xb1\x01\n" +
+ "\aKeyInfo\x12%\n" +
+ "\x0etouch_required\x18\x01 \x01(\bR\rtouchRequired\x12!\n" +
+ "\fpin_required\x18\x02 \x01(\bR\vpinRequired\x12\x1d\n" +
+ "\n" +
+ "proxy_host\x18\x03 \x01(\tR\tproxyHost\x12\x1a\n" +
+ "\busername\x18\x04 \x01(\tR\busername\x12!\n" +
+ "\fcluster_name\x18\x05 \x01(\tR\vclusterName*~\n" +
+ "\n" +
+ "PIVSlotKey\x12\x1c\n" +
+ "\x18PIV_SLOT_KEY_UNSPECIFIED\x10\x00\x12\x13\n" +
+ "\x0fPIV_SLOT_KEY_9A\x10\x01\x12\x13\n" +
+ "\x0fPIV_SLOT_KEY_9C\x10\x02\x12\x13\n" +
+ "\x0fPIV_SLOT_KEY_9D\x10\x03\x12\x13\n" +
+ "\x0fPIV_SLOT_KEY_9E\x10\x04*M\n" +
+ "\x04Hash\x12\x14\n" +
+ "\x10HASH_UNSPECIFIED\x10\x00\x12\r\n" +
+ "\tHASH_NONE\x10\x01\x12\x0f\n" +
+ "\vHASH_SHA256\x10\x02\x12\x0f\n" +
+ "\vHASH_SHA512\x10\x032\xd8\x01\n" +
+ "\x17HardwareKeyAgentService\x12_\n" +
+ "\x04Ping\x12).teleport.hardwarekeyagent.v1.PingRequest\x1a*.teleport.hardwarekeyagent.v1.PingResponse\"\x00\x12\\\n" +
+ "\x04Sign\x12).teleport.hardwarekeyagent.v1.SignRequest\x1a'.teleport.hardwarekeyagent.v1.Signature\"\x00BdZbgithub.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1;hardwarekeyagentv1b\x06proto3"
+
+var (
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescOnce sync.Once
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescData []byte
+)
+
+func file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescGZIP() []byte {
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescOnce.Do(func() {
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc), len(file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc)))
+ })
+ return file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDescData
+}
+
+var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
+var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_goTypes = []any{
+ (PIVSlotKey)(0), // 0: teleport.hardwarekeyagent.v1.PIVSlotKey
+ (Hash)(0), // 1: teleport.hardwarekeyagent.v1.Hash
+ (*PingRequest)(nil), // 2: teleport.hardwarekeyagent.v1.PingRequest
+ (*PingResponse)(nil), // 3: teleport.hardwarekeyagent.v1.PingResponse
+ (*SignRequest)(nil), // 4: teleport.hardwarekeyagent.v1.SignRequest
+ (*Signature)(nil), // 5: teleport.hardwarekeyagent.v1.Signature
+ (*KeyRef)(nil), // 6: teleport.hardwarekeyagent.v1.KeyRef
+ (*KeyInfo)(nil), // 7: teleport.hardwarekeyagent.v1.KeyInfo
+}
+var file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_depIdxs = []int32{
+ 1, // 0: teleport.hardwarekeyagent.v1.SignRequest.hash:type_name -> teleport.hardwarekeyagent.v1.Hash
+ 6, // 1: teleport.hardwarekeyagent.v1.SignRequest.key_ref:type_name -> teleport.hardwarekeyagent.v1.KeyRef
+ 7, // 2: teleport.hardwarekeyagent.v1.SignRequest.key_info:type_name -> teleport.hardwarekeyagent.v1.KeyInfo
+ 0, // 3: teleport.hardwarekeyagent.v1.KeyRef.slot_key:type_name -> teleport.hardwarekeyagent.v1.PIVSlotKey
+ 2, // 4: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:input_type -> teleport.hardwarekeyagent.v1.PingRequest
+ 4, // 5: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:input_type -> teleport.hardwarekeyagent.v1.SignRequest
+ 3, // 6: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Ping:output_type -> teleport.hardwarekeyagent.v1.PingResponse
+ 5, // 7: teleport.hardwarekeyagent.v1.HardwareKeyAgentService.Sign:output_type -> teleport.hardwarekeyagent.v1.Signature
+ 6, // [6:8] is the sub-list for method output_type
+ 4, // [4:6] is the sub-list for method input_type
+ 4, // [4:4] is the sub-list for extension type_name
+ 4, // [4:4] is the sub-list for extension extendee
+ 0, // [0:4] is the sub-list for field type_name
+}
+
+func init() { file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_init() }
+func file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_init() {
+ if File_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc), len(file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_rawDesc)),
+ NumEnums: 2,
+ NumMessages: 6,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_goTypes,
+ DependencyIndexes: file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_depIdxs,
+ EnumInfos: file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_enumTypes,
+ MessageInfos: file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_msgTypes,
+ }.Build()
+ File_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto = out.File
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_goTypes = nil
+ file_teleport_hardwarekeyagent_v1_hardwarekeyagent_service_proto_depIdxs = nil
+}
diff --git a/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service_grpc.pb.go b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service_grpc.pb.go
new file mode 100644
index 0000000000000..1a8cba08332e5
--- /dev/null
+++ b/api/gen/proto/go/teleport/hardwarekeyagent/v1/hardwarekeyagent_service_grpc.pb.go
@@ -0,0 +1,194 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc (unknown)
+// source: teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto
+
+package hardwarekeyagentv1
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ HardwareKeyAgentService_Ping_FullMethodName = "/teleport.hardwarekeyagent.v1.HardwareKeyAgentService/Ping"
+ HardwareKeyAgentService_Sign_FullMethodName = "/teleport.hardwarekeyagent.v1.HardwareKeyAgentService/Sign"
+)
+
+// HardwareKeyAgentServiceClient is the client API for HardwareKeyAgentService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+//
+// HardwareKeyAgentService provides an agent service for hardware key (PIV) signatures.
+// This allows multiple Teleport clients to share a PIV connection rather than blocking
+// each other, due to the exclusive nature of PIV connections. This also enabled shared
+// hardware key states, such as a custom PIN cache shared across Teleport clients.
+type HardwareKeyAgentServiceClient interface {
+ // Ping the agent service to check if it is active.
+ Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error)
+ // Sign produces a signature with the provided options for the specified hardware private key
+ //
+ // This rpc implements Go's crypto.Signer interface.
+ Sign(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*Signature, error)
+}
+
+type hardwareKeyAgentServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewHardwareKeyAgentServiceClient(cc grpc.ClientConnInterface) HardwareKeyAgentServiceClient {
+ return &hardwareKeyAgentServiceClient{cc}
+}
+
+func (c *hardwareKeyAgentServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(PingResponse)
+ err := c.cc.Invoke(ctx, HardwareKeyAgentService_Ping_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *hardwareKeyAgentServiceClient) Sign(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*Signature, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(Signature)
+ err := c.cc.Invoke(ctx, HardwareKeyAgentService_Sign_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// HardwareKeyAgentServiceServer is the server API for HardwareKeyAgentService service.
+// All implementations must embed UnimplementedHardwareKeyAgentServiceServer
+// for forward compatibility.
+//
+// HardwareKeyAgentService provides an agent service for hardware key (PIV) signatures.
+// This allows multiple Teleport clients to share a PIV connection rather than blocking
+// each other, due to the exclusive nature of PIV connections. This also enabled shared
+// hardware key states, such as a custom PIN cache shared across Teleport clients.
+type HardwareKeyAgentServiceServer interface {
+ // Ping the agent service to check if it is active.
+ Ping(context.Context, *PingRequest) (*PingResponse, error)
+ // Sign produces a signature with the provided options for the specified hardware private key
+ //
+ // This rpc implements Go's crypto.Signer interface.
+ Sign(context.Context, *SignRequest) (*Signature, error)
+ mustEmbedUnimplementedHardwareKeyAgentServiceServer()
+}
+
+// UnimplementedHardwareKeyAgentServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedHardwareKeyAgentServiceServer struct{}
+
+func (UnimplementedHardwareKeyAgentServiceServer) Ping(context.Context, *PingRequest) (*PingResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
+}
+func (UnimplementedHardwareKeyAgentServiceServer) Sign(context.Context, *SignRequest) (*Signature, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method Sign not implemented")
+}
+func (UnimplementedHardwareKeyAgentServiceServer) mustEmbedUnimplementedHardwareKeyAgentServiceServer() {
+}
+func (UnimplementedHardwareKeyAgentServiceServer) testEmbeddedByValue() {}
+
+// UnsafeHardwareKeyAgentServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to HardwareKeyAgentServiceServer will
+// result in compilation errors.
+type UnsafeHardwareKeyAgentServiceServer interface {
+ mustEmbedUnimplementedHardwareKeyAgentServiceServer()
+}
+
+func RegisterHardwareKeyAgentServiceServer(s grpc.ServiceRegistrar, srv HardwareKeyAgentServiceServer) {
+ // If the following call pancis, it indicates UnimplementedHardwareKeyAgentServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&HardwareKeyAgentService_ServiceDesc, srv)
+}
+
+func _HardwareKeyAgentService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(PingRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(HardwareKeyAgentServiceServer).Ping(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: HardwareKeyAgentService_Ping_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(HardwareKeyAgentServiceServer).Ping(ctx, req.(*PingRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _HardwareKeyAgentService_Sign_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(SignRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(HardwareKeyAgentServiceServer).Sign(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: HardwareKeyAgentService_Sign_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(HardwareKeyAgentServiceServer).Sign(ctx, req.(*SignRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// HardwareKeyAgentService_ServiceDesc is the grpc.ServiceDesc for HardwareKeyAgentService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var HardwareKeyAgentService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "teleport.hardwarekeyagent.v1.HardwareKeyAgentService",
+ HandlerType: (*HardwareKeyAgentServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "Ping",
+ Handler: _HardwareKeyAgentService_Ping_Handler,
+ },
+ {
+ MethodName: "Sign",
+ Handler: _HardwareKeyAgentService_Sign_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto",
+}
diff --git a/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto b/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto
new file mode 100644
index 0000000000000..e6b1481c9efaf
--- /dev/null
+++ b/api/proto/teleport/hardwarekeyagent/v1/hardwarekeyagent_service.proto
@@ -0,0 +1,129 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+syntax = "proto3";
+
+package teleport.hardwarekeyagent.v1;
+
+option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1;hardwarekeyagentv1";
+
+// HardwareKeyAgentService provides an agent service for hardware key (PIV) signatures.
+// This allows multiple Teleport clients to share a PIV connection rather than blocking
+// each other, due to the exclusive nature of PIV connections. This also enabled shared
+// hardware key states, such as a custom PIN cache shared across Teleport clients.
+service HardwareKeyAgentService {
+ // Ping the agent service to check if it is active.
+ rpc Ping(PingRequest) returns (PingResponse) {}
+ // Sign produces a signature with the provided options for the specified hardware private key
+ //
+ // This rpc implements Go's crypto.Signer interface.
+ rpc Sign(SignRequest) returns (Signature) {}
+}
+
+// PingRequest is a request to Ping.
+message PingRequest {}
+
+// PingResponse is a response to Ping.
+message PingResponse {
+ // PID is the PID of the client process running the agent.
+ uint32 pid = 1;
+}
+
+// SignRequest is a request to perform a signature with a specific hardware private key.
+message SignRequest {
+ // Digest is a hashed message to sign.
+ bytes digest = 1;
+ // Hash is the hash function used to prepare the digest.
+ Hash hash = 2;
+ // SaltLength specifies the length of the salt added to the digest before a signature.
+ // This salt length is precomputed by the client, following the crypto/rsa implementation.
+ // Only used, and required, for PSS RSA signatures.
+ uint32 salt_length = 3;
+ // KeyRef references a specific hardware private key.
+ KeyRef key_ref = 4;
+ // KeyInfo contains additional, optional key info which generally will improve UX by
+ // giving the agent context about the key, such as whether PIN/touch prompts are
+ // expected, or what cluster login is trying to interface with the key.
+ KeyInfo key_info = 5;
+ // Command is the client command or action requiring a signature, e.g. "tsh ssh server01".
+ // The agent can include this detail in PIN/touch prompts to show the origin of the
+ // signature request to the user.
+ string command = 6;
+}
+
+// Signature is a private key signature.
+message Signature {
+ // For an RSA key, signature should be either a PKCS #1 v1.5 or PSS signature,
+ // depending on the hash and salt chosen. For an (EC)DSA key, it should be a
+ // DER-serialised, ASN.1 signature structure.
+ bytes signature = 1;
+}
+
+// KeyRef references a specific hardware private key.
+message KeyRef {
+ // SerialNumber is the serial number of the hardware key.
+ uint32 serial_number = 1;
+ // SlotKey is a PIV slot key reference.
+ PIVSlotKey slot_key = 2;
+ // PublicKey is the public key encoded in PKIX, ASN.1 DER form. If the public key does
+ // not match the private key currently in the hardware key's PIV slot, the signature
+ // will fail early.
+ bytes public_key_der = 3;
+}
+
+// KeyInfo contains additional information about a hardware private key.
+message KeyInfo {
+ // TouchRequired is a client hint as to whether the hardware private key requires touch.
+ // The agent will use this to provide the ideal UX for the touch prompt. If this client
+ // hint is incorrect, touch will still be prompted.
+ bool touch_required = 1;
+ // PinRequired is a client hint as to whether the hardware private key requires PIN.
+ // The agent will use this to provide the ideal UX for the PIN prompt. If this client
+ // hint is incorrect, PIN will still be prompted for YubiKey versions >= 4.3.0, and
+ // failing with an auth error otherwise.
+ bool pin_required = 2;
+ // ProxyHost is a Teleport proxy hostname that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ string proxy_host = 3;
+ // Username is a Teleport username that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ string username = 4;
+ // ClusterName is a Teleport cluster name that the key is associated with.
+ // May be used to add context to PIN/touch prompts.
+ string cluster_name = 5;
+}
+
+// PIVSlotKey is the key reference for a specific PIV slot.
+enum PIVSlotKey {
+ // PIV slot key not specified.
+ PIV_SLOT_KEY_UNSPECIFIED = 0;
+ // PIV slot key 9a. This is the default slot for pin_policy=never, touch_policy=never.
+ PIV_SLOT_KEY_9A = 1;
+ // PIV slot key 9c. This is the default slot for pin_policy=never, touch_policy=cached.
+ PIV_SLOT_KEY_9C = 2;
+ // PIV slot key 9d. This is the default slot for pin_policy=once, touch_policy=cached.
+ PIV_SLOT_KEY_9D = 3;
+ // PIV slot key 9e. This is the default slot for pin_policy=once, touch_policy=never.
+ PIV_SLOT_KEY_9E = 4;
+}
+
+// Hash refers to a specific hash function used during signing.
+enum Hash {
+ HASH_UNSPECIFIED = 0;
+ HASH_NONE = 1;
+ HASH_SHA256 = 2;
+ HASH_SHA512 = 3;
+}
diff --git a/api/utils/keys/hardwarekey/hardwarekey.go b/api/utils/keys/hardwarekey/hardwarekey.go
index 49be739742ca7..fba453a87f970 100644
--- a/api/utils/keys/hardwarekey/hardwarekey.go
+++ b/api/utils/keys/hardwarekey/hardwarekey.go
@@ -241,6 +241,9 @@ type PrivateKeyConfig struct {
// - touch & pin -> 9d
// - touch & !pin -> 9e
CustomSlot PIVSlotKeyString
+ // Algorithm is the key algorithm to use. Defaults to [AlgorithmEC256].
+ // [AlgorithmEd25519] is not supported by all hardware keys.
+ Algorithm SignatureAlgorithm
// ContextualKeyInfo contains additional info to associate with the key.
ContextualKeyInfo ContextualKeyInfo
}
@@ -254,3 +257,12 @@ type ContextualKeyInfo struct {
// ClusterName is a Teleport cluster name that the key is associated with.
ClusterName string
}
+
+// SignatureAlgorithm is a signature key algorithm option.
+type SignatureAlgorithm int
+
+const (
+ SignatureAlgorithmEC256 SignatureAlgorithm = iota + 1
+ SignatureAlgorithmEd25519
+ SignatureAlgorithmRSA2048
+)
diff --git a/api/utils/keys/hardwarekey/service_mock.go b/api/utils/keys/hardwarekey/service_mock.go
index 5b8a1f4827e69..2625fb629493a 100644
--- a/api/utils/keys/hardwarekey/service_mock.go
+++ b/api/utils/keys/hardwarekey/service_mock.go
@@ -17,8 +17,11 @@ package hardwarekey
import (
"context"
"crypto"
+ "crypto/ecdsa"
"crypto/ed25519"
+ "crypto/elliptic"
"crypto/rand"
+ "crypto/rsa"
"io"
"sync"
"time"
@@ -90,7 +93,20 @@ func (s *MockHardwareKeyService) NewPrivateKey(ctx context.Context, config Priva
return nil, trace.Wrap(err)
}
- pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ var priv crypto.Signer
+ switch config.Algorithm {
+ // Use ECDSA key by default.
+ case SignatureAlgorithmEC256, 0:
+ priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ case SignatureAlgorithmEd25519:
+ _, priv, err = ed25519.GenerateKey(rand.Reader)
+ case SignatureAlgorithmRSA2048:
+ //nolint:forbidigo // Allow /api to generate RSA key without importing Teleport.
+ priv, err = rsa.GenerateKey(rand.Reader, 2048)
+ default:
+ return nil, trace.BadParameter("unknown algorithm option %v", config.Algorithm)
+ }
+
if err != nil {
return nil, trace.Wrap(err)
}
@@ -98,7 +114,7 @@ func (s *MockHardwareKeyService) NewPrivateKey(ctx context.Context, config Priva
ref := &PrivateKeyRef{
SerialNumber: serialNumber,
SlotKey: slotKey,
- PublicKey: pub,
+ PublicKey: priv.Public(),
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
@@ -129,7 +145,7 @@ func (s *MockHardwareKeyService) Sign(ctx context.Context, ref *PrivateKeyRef, k
slot: ref.SlotKey,
}]
if !ok {
- return nil, trace.NotFound("key not found in slot %d", ref.SlotKey)
+ return nil, trace.NotFound("key not found in slot 0x%x", ref.SlotKey)
}
if err := s.tryPrompt(ctx, ref.Policy, keyInfo); err != nil {
@@ -192,7 +208,7 @@ func (s *MockHardwareKeyService) GetFullKeyRef(serialNumber uint32, slotKey PIVS
slot: slotKey,
}]
if !ok {
- return nil, trace.NotFound("key not found in slot %d", slotKey)
+ return nil, trace.NotFound("key not found in slot 0x%x", slotKey)
}
return priv.ref, nil
@@ -201,3 +217,9 @@ func (s *MockHardwareKeyService) GetFullKeyRef(serialNumber uint32, slotKey PIVS
func (s *MockHardwareKeyService) MockTouch() {
s.mockTouch <- struct{}{}
}
+
+func (s *MockHardwareKeyService) Reset() {
+ s.fakeHardwarePrivateKeysMux.Lock()
+ defer s.fakeHardwarePrivateKeysMux.Unlock()
+ s.fakeHardwarePrivateKeys = map[hardwareKeySlot]*fakeHardwarePrivateKey{}
+}
diff --git a/api/utils/keys/hardwarekey/slot.go b/api/utils/keys/hardwarekey/slot.go
index 744b55631ee45..7d603dfa2ea9d 100644
--- a/api/utils/keys/hardwarekey/slot.go
+++ b/api/utils/keys/hardwarekey/slot.go
@@ -19,6 +19,8 @@ import (
"strconv"
"github.com/gravitational/trace"
+
+ hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1"
)
// PIVSlotKey is the key reference for a specific PIV slot.
@@ -27,23 +29,37 @@ import (
type PIVSlotKey uint32
const (
- pivSlotKeyBasic PIVSlotKey = 0x9a
- pivSlotKeyTouch PIVSlotKey = 0x9c
- pivSlotKeyTouchAndPIN PIVSlotKey = 0x9d
- pivSlotKeyPIN PIVSlotKey = 0x9e
+ pivSlot9A PIVSlotKey = 0x9a
+ pivSlot9C PIVSlotKey = 0x9c
+ pivSlot9D PIVSlotKey = 0x9d
+ pivSlot9E PIVSlotKey = 0x9e
)
+// Validate the slot key value.
+func (k PIVSlotKey) Validate() error {
+ switch k {
+ case pivSlot9A, pivSlot9C, pivSlot9D, pivSlot9E:
+ return nil
+ default:
+ return trace.BadParameter("invalid PIV slot key 0x%x", k)
+ }
+}
+
// GetDefaultSlotKey gets the default PIV slot key for the given [policy].
+// - 9A for PromptPolicyNone
+// - 9C for PromptPolicyTouch
+// - 9D for PromptPolicyTouchAndPIN
+// - 9E for PromptPolicyPIN
func GetDefaultSlotKey(policy PromptPolicy) (PIVSlotKey, error) {
switch policy {
case PromptPolicyNone:
- return pivSlotKeyBasic, nil
+ return pivSlot9A, nil
case PromptPolicyTouch:
- return pivSlotKeyTouch, nil
+ return pivSlot9C, nil
case PromptPolicyPIN:
- return pivSlotKeyPIN, nil
+ return pivSlot9E, nil
case PromptPolicyTouchAndPIN:
- return pivSlotKeyTouchAndPIN, nil
+ return pivSlot9D, nil
default:
return 0, trace.BadParameter("unexpected prompt policy %v", policy)
}
@@ -60,15 +76,54 @@ func (s PIVSlotKeyString) Validate() error {
// Parse [s] into a [PIVSlotKey].
func (s PIVSlotKeyString) Parse() (PIVSlotKey, error) {
- slotKey, err := strconv.ParseUint(string(s), 16, 32)
+ slotKeyUint, 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
+ slotKey := PIVSlotKey(slotKeyUint)
+ if err := slotKey.Validate(); err != nil {
+ return 0, trace.Wrap(err)
+ }
+
+ return slotKey, nil
+}
+
+// PIVSlotKeyFromProto convert the piv slot key from proto.
+func PIVSlotKeyFromProto(pivSlot hardwarekeyagentv1.PIVSlotKey) (PIVSlotKey, error) {
+ var slotKey PIVSlotKey
+ switch pivSlot {
+ case hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9A:
+ slotKey = pivSlot9A
+ case hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9C:
+ slotKey = pivSlot9C
+ case hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9D:
+ slotKey = pivSlot9D
+ case hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9E:
+ slotKey = pivSlot9E
+ default:
+ return 0, trace.BadParameter("unknown piv slot key for proto enum %d", pivSlot)
+ }
+
+ if err := slotKey.Validate(); err != nil {
+ return 0, trace.Wrap(err)
+ }
+
+ return slotKey, nil
+}
+
+// PIVSlotKeyFromProto convert the piv slot key to proto.
+func PIVSlotKeyToProto(slotKey PIVSlotKey) (hardwarekeyagentv1.PIVSlotKey, error) {
+ switch slotKey {
+ case pivSlot9A:
+ return hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9A, nil
+ case pivSlot9C:
+ return hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9C, nil
+ case pivSlot9D:
+ return hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9D, nil
+ case pivSlot9E:
+ return hardwarekeyagentv1.PIVSlotKey_PIV_SLOT_KEY_9E, nil
default:
- return 0, trace.BadParameter("invalid PIV slot %q", s)
+ return 0, trace.BadParameter("unknown proto enum for piv slot key %d", slotKey)
}
}
diff --git a/api/utils/keys/hardwarekeyagent/agent.go b/api/utils/keys/hardwarekeyagent/agent.go
new file mode 100644
index 0000000000000..270a7932734f9
--- /dev/null
+++ b/api/utils/keys/hardwarekeyagent/agent.go
@@ -0,0 +1,139 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package hardwarekeyagent provides a hardware key agent implementation of [hardwarekey.Service].
+package hardwarekeyagent
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "net"
+ "os"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1"
+ "github.com/gravitational/teleport/api/utils/grpc/interceptors"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+)
+
+// NewClient creates a new hardware key agent client.
+func NewClient(ctx context.Context, socketPath string, creds credentials.TransportCredentials) (hardwarekeyagentv1.HardwareKeyAgentServiceClient, error) {
+ if _, err := os.Stat(socketPath); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ cc, err := grpc.NewClient("passthrough:",
+ grpc.WithTransportCredentials(creds),
+ grpc.WithUnaryInterceptor(interceptors.GRPCClientUnaryErrorInterceptor),
+ // The [grpc] library fails to resolve unix sockets on Windows, so
+ // we provide "passthrough:" to skip grpc's address resolution and
+ // a custom [net] dialer to connect to the socket.
+ grpc.WithContextDialer(func(_ context.Context, addr string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ }),
+ )
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return hardwarekeyagentv1.NewHardwareKeyAgentServiceClient(cc), nil
+}
+
+// NewServer returns a new hardware key agent server.
+func NewServer(ctx context.Context, s hardwarekey.Service, creds credentials.TransportCredentials) *grpc.Server {
+ grpcServer := grpc.NewServer(
+ grpc.Creds(creds),
+ grpc.UnaryInterceptor(interceptors.GRPCServerUnaryErrorInterceptor),
+ )
+ hardwarekeyagentv1.RegisterHardwareKeyAgentServiceServer(grpcServer, &agentService{s: s})
+ return grpcServer
+}
+
+// agentService implements [hardwarekeyagentv1.HardwareKeyAgentServiceServer].
+type agentService struct {
+ hardwarekeyagentv1.UnimplementedHardwareKeyAgentServiceServer
+ s hardwarekey.Service
+}
+
+// Sign the given digest with the specified hardware private key.
+func (s *agentService) Sign(ctx context.Context, req *hardwarekeyagentv1.SignRequest) (*hardwarekeyagentv1.Signature, error) {
+ slotKey, err := hardwarekey.PIVSlotKeyFromProto(req.KeyRef.SlotKey)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ pub, err := x509.ParsePKIXPublicKey(req.KeyRef.PublicKeyDer)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ keyRef := &hardwarekey.PrivateKeyRef{
+ SerialNumber: req.KeyRef.SerialNumber,
+ SlotKey: slotKey,
+ PublicKey: pub,
+ Policy: hardwarekey.PromptPolicy{
+ TouchRequired: req.KeyInfo.TouchRequired,
+ PINRequired: req.KeyInfo.PinRequired,
+ },
+ }
+
+ keyInfo := hardwarekey.ContextualKeyInfo{
+ ProxyHost: req.KeyInfo.ProxyHost,
+ Username: req.KeyInfo.Username,
+ ClusterName: req.KeyInfo.ClusterName,
+ }
+
+ var signerOpts crypto.SignerOpts
+ switch req.Hash {
+ case hardwarekeyagentv1.Hash_HASH_NONE:
+ signerOpts = crypto.Hash(0)
+ case hardwarekeyagentv1.Hash_HASH_SHA256:
+ signerOpts = crypto.SHA256
+ case hardwarekeyagentv1.Hash_HASH_SHA512:
+ signerOpts = crypto.SHA512
+ default:
+ return nil, trace.BadParameter("unsupported hash %q", req.Hash.String())
+ }
+
+ if req.SaltLength > 0 {
+ signerOpts = &rsa.PSSOptions{
+ Hash: signerOpts.HashFunc(),
+ SaltLength: int(req.SaltLength),
+ }
+ }
+
+ signature, err := s.s.Sign(ctx, keyRef, keyInfo, rand.Reader, req.Digest, signerOpts)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &hardwarekeyagentv1.Signature{
+ Signature: signature,
+ }, nil
+}
+
+// Ping the server and get its PID.
+func (s *agentService) Ping(ctx context.Context, req *hardwarekeyagentv1.PingRequest) (*hardwarekeyagentv1.PingResponse, error) {
+ return &hardwarekeyagentv1.PingResponse{
+ Pid: uint32(os.Getpid()),
+ }, nil
+}
diff --git a/api/utils/keys/hardwarekeyagent/service.go b/api/utils/keys/hardwarekeyagent/service.go
new file mode 100644
index 0000000000000..a00a92580dddd
--- /dev/null
+++ b/api/utils/keys/hardwarekeyagent/service.go
@@ -0,0 +1,168 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package hardwarekeyagent
+
+import (
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/x509"
+ "io"
+ "log/slog"
+ "math"
+
+ "github.com/gravitational/trace"
+
+ hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+)
+
+// Service is an agent implementation of [hardwarekey.AgentService].
+type Service struct {
+ // Used for signature requests to the service.
+ agentClient hardwarekeyagentv1.HardwareKeyAgentServiceClient
+ // Used for non signature methods and as a fallback for signatures if the
+ // agent client fails to handle a sign request.
+ fallbackService hardwarekey.Service
+}
+
+// NewService creates a new hardware key agent service from the given
+// agent client and fallback service. The fallback service is used for
+// non-signature methods of [hardwarekey.Service] which are not implemented
+// by the agent. Generally this fallback service is only used during login.
+func NewService(agentClient hardwarekeyagentv1.HardwareKeyAgentServiceClient, fallbackService hardwarekey.Service) *Service {
+ return &Service{
+ agentClient: agentClient,
+ fallbackService: fallbackService,
+ }
+}
+
+// NewPrivateKey creates or retrieves a hardware private key for the given config.
+func (s *Service) NewPrivateKey(ctx context.Context, config hardwarekey.PrivateKeyConfig) (*hardwarekey.Signer, error) {
+ return s.fallbackService.NewPrivateKey(ctx, config)
+}
+
+// Sign performs a cryptographic signature using the specified hardware
+// private key and provided signature parameters.
+func (s *Service) Sign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, keyInfo hardwarekey.ContextualKeyInfo, rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
+ // First try to sign with the agent, then fallback to the direct service if needed.
+ signature, err := s.agentSign(ctx, ref, keyInfo, rand, digest, opts)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to perform signature over hardware key agent, falling back to fallback service", "agent_err", err)
+ signature, err = s.fallbackService.Sign(ctx, ref, keyInfo, rand, digest, opts)
+ }
+
+ return signature, trace.Wrap(err)
+}
+
+func (s *Service) agentSign(ctx context.Context, ref *hardwarekey.PrivateKeyRef, keyInfo hardwarekey.ContextualKeyInfo, rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
+ slotKey, err := hardwarekey.PIVSlotKeyToProto(ref.SlotKey)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ publicKeyDER, err := x509.MarshalPKIXPublicKey(ref.PublicKey)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ var hash hardwarekeyagentv1.Hash
+ switch opts.HashFunc() {
+ case 0:
+ hash = hardwarekeyagentv1.Hash_HASH_NONE
+ case crypto.SHA256:
+ hash = hardwarekeyagentv1.Hash_HASH_SHA256
+ case crypto.SHA512:
+ hash = hardwarekeyagentv1.Hash_HASH_SHA512
+ default:
+ return nil, trace.BadParameter("unsupported hash func %q", opts.HashFunc().String())
+ }
+
+ var saltLength int
+ if pssOpts, ok := opts.(*rsa.PSSOptions); ok {
+ if pssOpts.Hash == 0 {
+ return nil, trace.BadParameter("hash must be specified for PSS signature")
+ }
+
+ rsaPub, ok := ref.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ return nil, trace.BadParameter("cannot perform PSS signature for non-rsa key")
+ }
+
+ saltLength = pssOpts.SaltLength
+
+ // If the salt length is [rsa.PSSSaltLengthEqualsHash] or [rsa.PSSSaltLengthAuto],
+ // pre-calculate the salt length so we can send it over the gRPC message.
+ switch saltLength {
+ case rsa.PSSSaltLengthEqualsHash:
+ saltLength = pssOpts.Hash.Size()
+ case rsa.PSSSaltLengthAuto:
+ // We use the same salt length calculation as the crypto/rsa package.
+ // https://github.com/golang/go/blob/21483099632c11743d01ec6f38577f31de26b0d0/src/crypto/internal/fips140/rsa/pkcs1v22.go#L253
+ saltLength = (rsaPub.N.BitLen()-1+7)/8 - 2 - pssOpts.Hash.Size()
+ }
+
+ if saltLength < 0 {
+ return nil, rsa.ErrMessageTooLong
+ }
+
+ if saltLength > math.MaxUint32 {
+ return nil, trace.BadParameter("invalid salt length %d", saltLength)
+ }
+ }
+
+ req := &hardwarekeyagentv1.SignRequest{
+ Digest: digest,
+ Hash: hash,
+ SaltLength: uint32(saltLength),
+ KeyRef: &hardwarekeyagentv1.KeyRef{
+ SerialNumber: ref.SerialNumber,
+ SlotKey: slotKey,
+ PublicKeyDer: publicKeyDER,
+ },
+ KeyInfo: &hardwarekeyagentv1.KeyInfo{
+ TouchRequired: ref.Policy.TouchRequired,
+ PinRequired: ref.Policy.PINRequired,
+ ProxyHost: keyInfo.ProxyHost,
+ Username: keyInfo.Username,
+ ClusterName: keyInfo.ClusterName,
+ },
+ // TODO: Add command to sign request for prompt context.
+ }
+
+ resp, err := s.agentClient.Sign(ctx, req)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return resp.Signature, nil
+}
+
+// TODO(Joerger): DELETE IN v19.0.0
+func (s *Service) GetFullKeyRef(serialNumber uint32, slotKey hardwarekey.PIVSlotKey) (*hardwarekey.PrivateKeyRef, error) {
+ return s.fallbackService.GetFullKeyRef(serialNumber, slotKey)
+}
+
+// SetPrompt for the fallback service.
+func (s *Service) SetPrompt(prompt hardwarekey.Prompt) {
+ s.fallbackService.SetPrompt(prompt)
+}
+
+// GetPrompt for the fallback service.
+func (s *Service) GetPrompt() hardwarekey.Prompt {
+ return s.fallbackService.GetPrompt()
+}
diff --git a/api/utils/keys/hardwarekeyagent/service_test.go b/api/utils/keys/hardwarekeyagent/service_test.go
new file mode 100644
index 0000000000000..a78895103bbea
--- /dev/null
+++ b/api/utils/keys/hardwarekeyagent/service_test.go
@@ -0,0 +1,213 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package hardwarekeyagent_test
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "net"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekeyagent"
+)
+
+func TestHardwareKeyAgentService(t *testing.T) {
+ ctx := context.Background()
+
+ // Prepare the agent server
+ mockService := hardwarekey.NewMockHardwareKeyService(nil /*prompt*/)
+ server := hardwarekeyagent.NewServer(ctx, mockService, insecure.NewCredentials())
+ t.Cleanup(server.Stop)
+
+ agentDir := t.TempDir()
+ socketPath := filepath.Join(agentDir, "agent.sock")
+ l, err := net.Listen("unix", socketPath)
+ require.NoError(t, err)
+
+ serverErr := make(chan error, 1)
+ go func() {
+ serverErr <- server.Serve(l)
+ }()
+
+ // Prepare the agent client
+ agentClient, err := hardwarekeyagent.NewClient(ctx, socketPath, insecure.NewCredentials())
+ require.NoError(t, err)
+
+ agentService := hardwarekeyagent.NewService(agentClient, hardwarekey.NewMockHardwareKeyService(nil /*prompt*/))
+ agentServiceWithFallback := hardwarekeyagent.NewService(agentClient, mockService)
+
+ for _, tc := range []struct {
+ name string
+ algorithm hardwarekey.SignatureAlgorithm
+ opts crypto.SignerOpts
+ expectErr bool
+ }{
+ {
+ name: "EC256 Unsupported hash",
+ algorithm: hardwarekey.SignatureAlgorithmEC256,
+ opts: crypto.MD5,
+ expectErr: true, // unsupported hash
+ },
+ {
+ name: "EC256 No hash",
+ algorithm: hardwarekey.SignatureAlgorithmEC256,
+ opts: crypto.Hash(0),
+ },
+ {
+ name: "EC256 SHA256",
+ algorithm: hardwarekey.SignatureAlgorithmEC256,
+ opts: crypto.SHA256,
+ },
+ {
+ name: "EC256 SHA512",
+ algorithm: hardwarekey.SignatureAlgorithmEC256,
+ opts: crypto.SHA512,
+ },
+ {
+ name: "ED25519 No hash",
+ algorithm: hardwarekey.SignatureAlgorithmEd25519,
+ opts: crypto.Hash(0),
+ },
+ {
+ name: "ED25519 SHA256",
+ algorithm: hardwarekey.SignatureAlgorithmEd25519,
+ opts: crypto.SHA256,
+ expectErr: true, // sha256 not supported
+ },
+ {
+ name: "ED25519 SHA512",
+ algorithm: hardwarekey.SignatureAlgorithmEd25519,
+ opts: crypto.SHA512,
+ },
+ {
+ name: "RSA2048 No hash",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: crypto.Hash(0),
+ },
+ {
+ name: "RSA2048 SHA256",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: crypto.SHA256,
+ },
+ {
+ name: "RSA2048 SHA512",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: crypto.SHA512,
+ },
+ {
+ name: "RSA2048 No hash PSS signature",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: 10,
+ Hash: crypto.Hash(0),
+ },
+ expectErr: true, // hash required for pss signature
+ },
+ {
+ name: "RSA2048 SHA256 PSS signature",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: 10,
+ Hash: crypto.SHA256,
+ },
+ },
+ {
+ name: "RSA2048 SHA256 PSSSaltLengthAuto",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: rsa.PSSSaltLengthAuto,
+ Hash: crypto.SHA256,
+ },
+ },
+ {
+ name: "RSA2048 SHA256 PSSSaltLengthEqualsHash",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: rsa.PSSSaltLengthEqualsHash,
+ Hash: crypto.SHA256,
+ },
+ },
+ {
+ name: "RSA2048 SHA512 PSS signature",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: 10,
+ Hash: crypto.SHA512,
+ },
+ },
+ {
+ name: "RSA2048 SHA512 PSSSaltLengthAuto",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: rsa.PSSSaltLengthAuto,
+ Hash: crypto.SHA512,
+ },
+ },
+ {
+ name: "RSA2048 SHA512 PSSSaltLengthEqualsHash",
+ algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ opts: &rsa.PSSOptions{
+ SaltLength: rsa.PSSSaltLengthEqualsHash,
+ Hash: crypto.SHA512,
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Cleanup(mockService.Reset)
+
+ // Create a new key directly in the service.
+ hwSigner, err := mockService.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{
+ Algorithm: tc.algorithm,
+ })
+ require.NoError(t, err)
+
+ // Mock a hashed digest.
+ digest := make([]byte, 100)
+ if hash := tc.opts.HashFunc(); hash != 0 {
+ digest = make([]byte, hash.Size())
+ }
+
+ // Perform a signature over the agent.
+ _, err = agentService.Sign(ctx, hwSigner.Ref, hwSigner.KeyInfo, rand.Reader, digest, tc.opts)
+ if tc.expectErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+
+ t.Run("fallback", func(t *testing.T) {
+ mockService.Reset()
+
+ // Create a new key.
+ hwSigner, err := agentServiceWithFallback.NewPrivateKey(ctx, hardwarekey.PrivateKeyConfig{})
+ require.NoError(t, err)
+
+ // If the server stops, the service should fallback to the fallback service.
+ server.Stop()
+ err = hwSigner.WarmupHardwareKey(ctx)
+ require.NoError(t, err)
+ })
+}
diff --git a/api/utils/keys/piv/service.go b/api/utils/keys/piv/service.go
index 3633922a87e0f..90f1425011225 100644
--- a/api/utils/keys/piv/service.go
+++ b/api/utils/keys/piv/service.go
@@ -122,7 +122,7 @@ func (s *YubiKeyService) NewPrivateKey(ctx context.Context, config hardwarekey.P
}
generatePrivateKey := func() (*hardwarekey.Signer, error) {
- ref, err := y.generatePrivateKey(pivSlot, config.Policy)
+ ref, err := y.generatePrivateKey(pivSlot, config.Policy, config.Algorithm)
if err != nil {
return nil, trace.Wrap(err)
}
diff --git a/api/utils/keys/piv/service_test.go b/api/utils/keys/piv/service_test.go
index 1bce1e1f64441..f2993f2b2163f 100644
--- a/api/utils/keys/piv/service_test.go
+++ b/api/utils/keys/piv/service_test.go
@@ -34,8 +34,8 @@ import (
"github.com/gravitational/teleport/api/utils/prompt"
)
-// TestGetYubiKeyPrivateKey_Interactive tests generation and retrieval of YubiKey private keys.
-func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
+// TestNewHardwarePrivateKey_Interactive tests generation and retrieval of YubiKey private keys.
+func TestNewHardwarePrivateKey_Interactive(t *testing.T) {
// 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")
@@ -56,74 +56,103 @@ func TestGetYubiKeyPrivateKey_Interactive(t *testing.T) {
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.NoError(t, priv.WarmupHardwareKey(ctx))
+ // Test creating a new key, retrieving it, and using it for signatures.
+ testNewPrivateKey := func(t *testing.T, config hardwarekey.PrivateKeyConfig) {
+ t.Helper()
+
+ // NewHardwarePrivateKey should generate a new hardware private key.
+ priv, err := keys.NewHardwarePrivateKey(ctx, s, config)
+ require.NoError(t, err)
+ hwSigner, ok := priv.Signer.(*hardwarekey.Signer)
+ require.True(t, ok, "expected hardwarekey.Signer but got %T", priv.Signer)
+
+ // Check that config was applied correctly.
+ require.Equal(t, config.Policy, hwSigner.Ref.Policy)
+ if config.CustomSlot != "" {
+ expectSlot, err := config.CustomSlot.Parse()
+ require.NoError(t, err)
+ require.Equal(t, expectSlot, hwSigner.Ref.SlotKey)
+ }
+
+ // test Hardware Key methods
+ require.Equal(t, config.Policy, priv.GetPrivateKeyPolicy().GetPromptPolicy())
+ require.NotNil(t, priv.GetAttestationStatement())
+ require.True(t, priv.IsHardware())
+
+ // Test bogus sign (warmup).
+ require.NoError(t, priv.WarmupHardwareKey(ctx))
- // Set pin and handle expected prompts.
- setupPINPrompt := func(t *testing.T) {
+ // Another call to NewHardwarePrivateKey should retrieve the previously generated key.
+ retrievePriv, err := keys.NewHardwarePrivateKey(ctx, s, config)
+ require.NoError(t, err)
+ require.Equal(t, priv.Public(), retrievePriv.Public())
+
+ // parsing the key's private key PEM should produce the same key as well.
+ retrievePriv, err = keys.ParsePrivateKey(priv.PrivateKeyPEM(), keys.WithHardwareKeyService(s))
+ require.NoError(t, err)
+ require.Equal(t, priv.Public(), retrievePriv.Public())
+ }
+
+ t.Run("PromptPolicies", func(t *testing.T) {
+ // Warmup the hardware key to prompt touch at the start of the test,
+ // rather than having this interaction later.
+ priv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
+ Policy: hardwarekey.PromptPolicy{TouchRequired: true},
+ })
+ require.NoError(t, err)
+ require.NoError(t, priv.WarmupHardwareKey(ctx))
+
+ resetYubikey(t, y)
+
+ // Set pin.
const testPIN = "123123"
require.NoError(t, y.SetPIN(pivgo.DefaultPIN, testPIN))
- promptReader.AddString(testPIN).AddString(testPIN)
- }
- for _, policy := range []hardwarekey.PromptPolicy{
- hardwarekey.PromptPolicyNone,
- hardwarekey.PromptPolicyTouch,
- hardwarekey.PromptPolicyPIN,
- hardwarekey.PromptPolicyTouchAndPIN,
- } {
- for _, customSlot := range []bool{true, false} {
+ for _, policy := range []hardwarekey.PromptPolicy{
+ hardwarekey.PromptPolicyNone,
+ hardwarekey.PromptPolicyTouch,
+ hardwarekey.PromptPolicyPIN,
+ hardwarekey.PromptPolicyTouchAndPIN,
+ } {
t.Run(fmt.Sprintf("policy:%+v", policy), func(t *testing.T) {
- t.Run(fmt.Sprintf("custom slot:%v", customSlot), func(t *testing.T) {
- setupPINPrompt(t)
- t.Cleanup(func() {
- resetYubikey(t, y)
- })
-
- var slot hardwarekey.PIVSlotKeyString = ""
- if customSlot {
- slot = "9a"
- }
-
- // NewHardwarePrivateKey should generate a new hardware private key.
- priv, err := keys.NewHardwarePrivateKey(ctx, s, hardwarekey.PrivateKeyConfig{
- CustomSlot: slot,
- Policy: policy,
- })
- require.NoError(t, err)
-
- // test HardwareSigner methods
- require.Equal(t, policy, priv.GetPrivateKeyPolicy().GetPromptPolicy())
- require.NotNil(t, priv.GetAttestationStatement())
- require.True(t, priv.IsHardware())
-
- // Test bogus sign (warmup).
- require.NoError(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: policy,
- })
- require.NoError(t, err)
- require.Equal(t, priv.Public(), retrievePriv.Public())
-
- // parsing the key's private key PEM should produce the same key as well.
- retrievePriv, err = keys.ParsePrivateKey(priv.PrivateKeyPEM(), keys.WithHardwareKeyService(s))
- require.NoError(t, err)
- require.Equal(t, priv.Public(), retrievePriv.Public())
+ // Handle pin prompts (1 for generating the key, 1 for signing).
+ if policy.PINRequired {
+ promptReader.AddString(testPIN).AddString(testPIN)
+ }
+
+ testNewPrivateKey(t, hardwarekey.PrivateKeyConfig{
+ Policy: policy,
})
})
}
- }
+ })
+
+ t.Run("CustomSlot", func(t *testing.T) {
+ resetYubikey(t, y)
+ testNewPrivateKey(t, hardwarekey.PrivateKeyConfig{
+ CustomSlot: "9c",
+ })
+ })
+
+ t.Run("Algorithms", func(t *testing.T) {
+ for algorithm, config := range map[string]hardwarekey.PrivateKeyConfig{
+ "EC256": {
+ CustomSlot: "9a",
+ Algorithm: hardwarekey.SignatureAlgorithmEC256,
+ },
+ "RSA2048": {
+ CustomSlot: "9c",
+ Algorithm: hardwarekey.SignatureAlgorithmRSA2048,
+ },
+ } {
+ t.Run(fmt.Sprintf("algorithm:%v", algorithm), func(t *testing.T) {
+ resetYubikey(t, y)
+ testNewPrivateKey(t, config)
+ })
+ }
+ })
}
func TestOverwritePrompt(t *testing.T) {
diff --git a/api/utils/keys/piv/yubikey.go b/api/utils/keys/piv/yubikey.go
index bee6f3f86e66e..a31ed2b0b98a1 100644
--- a/api/utils/keys/piv/yubikey.go
+++ b/api/utils/keys/piv/yubikey.go
@@ -290,7 +290,7 @@ func (y *YubiKey) Reset() error {
}
// generatePrivateKey generates a new private key in the given PIV slot.
-func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPolicy) (*hardwarekey.PrivateKeyRef, error) {
+func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPolicy, algorithm hardwarekey.SignatureAlgorithm) (*hardwarekey.PrivateKeyRef, error) {
touchPolicy := piv.TouchPolicyNever
if policy.TouchRequired {
touchPolicy = piv.TouchPolicyCached
@@ -301,8 +301,24 @@ func (y *YubiKey) generatePrivateKey(slot piv.Slot, policy hardwarekey.PromptPol
pinPolicy = piv.PINPolicyOnce
}
+ var alg piv.Algorithm
+ switch algorithm {
+ // Use ECDSA key by default.
+ case hardwarekey.SignatureAlgorithmEC256, 0:
+ alg = piv.AlgorithmEC256
+ case hardwarekey.SignatureAlgorithmEd25519:
+ // TODO(Joerger): Currently algorithms are only specified in tests, but some users pre-generate
+ // their keys in custom slots with custom algorithms, so we should try to support Ed25519 keys.
+ // Currently the Ed25519 algorithm is only supported by SoloKeys and YubiKeys v5.7.x+
+ return nil, trace.BadParameter("Ed25519 keys are not currently supported")
+ case hardwarekey.SignatureAlgorithmRSA2048:
+ alg = piv.AlgorithmRSA2048
+ default:
+ return nil, trace.BadParameter("unknown algorithm option %v", algorithm)
+ }
+
opts := piv.Key{
- Algorithm: piv.AlgorithmEC256,
+ Algorithm: alg,
PINPolicy: pinPolicy,
TouchPolicy: touchPolicy,
}
diff --git a/lib/client/api.go b/lib/client/api.go
index 83a4026643872..1b636665d0bf0 100644
--- a/lib/client/api.go
+++ b/lib/client/api.go
@@ -72,7 +72,6 @@ import (
"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"
@@ -87,6 +86,7 @@ import (
"github.com/gravitational/teleport/lib/devicetrust"
dtauthntypes "github.com/gravitational/teleport/lib/devicetrust/authn/types"
"github.com/gravitational/teleport/lib/events"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/multiplexer"
"github.com/gravitational/teleport/lib/observability/tracing"
@@ -1293,7 +1293,7 @@ func NewClient(c *Config) (tc *TeleportClient, err error) {
} else {
// TODO (Joerger): init hardware key service (and client store) earlier where it can
// be properly shared.
- hardwareKeyService := piv.NewYubiKeyService(tc.CustomHardwareKeyPrompt)
+ hardwareKeyService := libhwk.NewService(context.TODO(), tc.CustomHardwareKeyPrompt)
tc.ClientStore = NewFSClientStore(c.KeysDir, WithHardwareKeyService(hardwareKeyService))
if c.AddKeysToAgent == AddKeysToAgentOnly {
// Store client keys in memory, but still save trusted certs and profile to disk.
diff --git a/lib/hardwarekey/agent.go b/lib/hardwarekey/agent.go
new file mode 100644
index 0000000000000..090ba9b9143ee
--- /dev/null
+++ b/lib/hardwarekey/agent.go
@@ -0,0 +1,168 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package hardwarekey
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1"
+ "github.com/gravitational/teleport/api/utils/keys"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekeyagent"
+ "github.com/gravitational/teleport/lib/utils/cert"
+)
+
+const (
+ dirName = ".Teleport-PIV"
+ sockName = "agent.sock"
+ certFileName = "cert.pem"
+)
+
+// DefaultAgentDir is the default dir for the hardware key agent's socket and certificate files.
+func DefaultAgentDir() string {
+ return filepath.Join(os.TempDir(), dirName)
+}
+
+// NewAgentClient opens a new hardware key agent client connected to the
+// server based out of the given directory.
+//
+// [DefaultAgentDir] should be used for [keyAgentDir] outside of tests.
+func NewAgentClient(ctx context.Context, keyAgentDir string) (hardwarekeyagentv1.HardwareKeyAgentServiceClient, error) {
+ socketPath := filepath.Join(keyAgentDir, sockName)
+ certPath := filepath.Join(keyAgentDir, certFileName)
+
+ creds, err := credentials.NewClientTLSFromFile(certPath, "localhost")
+ if err != nil {
+ return nil, err
+ }
+
+ return hardwarekeyagent.NewClient(ctx, socketPath, creds)
+}
+
+// Server implementation [hardwarekeyagentv1.HardwareKeyAgentServiceServer].
+type Server struct {
+ grpcServer *grpc.Server
+ listener net.Listener
+ dir string
+}
+
+// NewAgentServer returns a new hardware key agent server based out of the given directory.
+// The given directory will be created when the server is served and destroyed with the server is stopped.
+//
+// [DefaultAgentDir] should be used for [keyAgentDir] outside of tests.
+func NewAgentServer(ctx context.Context, s hardwarekey.Service, keyAgentDir string) (*Server, error) {
+ if err := os.MkdirAll(keyAgentDir, 0o700); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ l, err := newAgentListener(ctx, keyAgentDir)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ cert, err := generateServerCert(keyAgentDir)
+ if err != nil {
+ l.Close()
+ return nil, trace.Wrap(err)
+ }
+
+ grpcServer := hardwarekeyagent.NewServer(ctx, s, credentials.NewServerTLSFromCert(&cert))
+ return &Server{
+ grpcServer: grpcServer,
+ listener: l,
+ dir: keyAgentDir,
+ }, nil
+}
+
+func newAgentListener(ctx context.Context, keyAgentDir string) (net.Listener, error) {
+ socketPath := filepath.Join(keyAgentDir, sockName)
+ l, err := net.Listen("unix", socketPath)
+ if err == nil {
+ return l, nil
+ } else if !errors.Is(err, syscall.EADDRINUSE) {
+ return nil, trace.Wrap(err)
+ }
+
+ // A hardware key agent already exists in the given path. Before replacing it,
+ // try to connect to it and see if it is active.
+ client, err := NewAgentClient(ctx, keyAgentDir)
+ if err == nil {
+ pong, err := client.Ping(ctx, &hardwarekeyagentv1.PingRequest{})
+ if err == nil {
+ return nil, trace.AlreadyExists("another agent instance is already running; PID: %d", pong.Pid)
+ }
+ }
+
+ // If it isn't running, remove the socket and try again.
+ if err := os.Remove(socketPath); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if l, err = net.Listen("unix", socketPath); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return l, nil
+}
+
+func generateServerCert(keyAgentDir string) (tls.Certificate, error) {
+ creds, err := cert.GenerateSelfSignedCert([]string{"localhost"}, nil /*ipAddresses*/)
+ if err != nil {
+ return tls.Certificate{}, trace.Wrap(err, "failed to generate the certificate")
+ }
+
+ certPath := filepath.Join(keyAgentDir, certFileName)
+ f, err := os.OpenFile(certPath, os.O_RDWR|os.O_CREATE, 0o600)
+ if err != nil {
+ return tls.Certificate{}, trace.Wrap(err)
+ }
+ if _, err = f.Write(creds.Cert); err != nil {
+ return tls.Certificate{}, trace.Wrap(err)
+ }
+ if err = f.Close(); err != nil {
+ return tls.Certificate{}, trace.Wrap(err)
+ }
+
+ return keys.X509KeyPair(creds.Cert, creds.PrivateKey)
+}
+
+// Serve the hardware key agent server.
+func (s *Server) Serve(ctx context.Context) error {
+ fmt.Fprintln(os.Stderr, "Listening for hardware key agent requests")
+ context.AfterFunc(ctx, s.Stop)
+ return trace.Wrap(s.grpcServer.Serve(s.listener))
+}
+
+// Stop the hardware key agent server.
+func (s *Server) Stop() {
+ s.grpcServer.GracefulStop()
+ if err := os.RemoveAll(s.dir); err != nil {
+ slog.DebugContext(context.TODO(), "failed to clear hardware key agent directory")
+ }
+}
diff --git a/lib/hardwarekey/agent_test.go b/lib/hardwarekey/agent_test.go
new file mode 100644
index 0000000000000..99a551fa9db8d
--- /dev/null
+++ b/lib/hardwarekey/agent_test.go
@@ -0,0 +1,75 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package hardwarekey_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ hardwarekeyagentv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/hardwarekeyagent/v1"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
+)
+
+func TestHardwareKeyAgent_Server(t *testing.T) {
+ ctx := context.Background()
+ agentDir := t.TempDir()
+
+ // Prepare the agent server
+ mockService := hardwarekey.NewMockHardwareKeyService(nil /*prompt*/)
+ server, err := libhwk.NewAgentServer(ctx, mockService, agentDir)
+ require.NoError(t, err)
+ t.Cleanup(server.Stop)
+
+ serverErr := make(chan error, 1)
+ go func() {
+ serverErr <- server.Serve(ctx)
+ }()
+
+ // Should fail to open a new server in the same directory.
+ _, err = libhwk.NewAgentServer(ctx, mockService, agentDir)
+ require.Error(t, err)
+
+ // Existing server should be unaffected.
+ clt, err := libhwk.NewAgentClient(ctx, agentDir)
+ require.NoError(t, err)
+ _, err = clt.Ping(ctx, &hardwarekeyagentv1.PingRequest{})
+ require.NoError(t, err)
+
+ // If the server stops gracefully, the directory should be cleaned up and a new server can be started.
+ server.Stop()
+ require.Eventually(t, func() bool {
+ _, err := os.Stat(agentDir)
+ return errors.Is(err, os.ErrNotExist)
+ }, 5*time.Second, 100*time.Millisecond)
+ server, err = libhwk.NewAgentServer(ctx, mockService, agentDir)
+ require.NoError(t, err)
+ t.Cleanup(server.Stop)
+
+ // If the server is unresponsive, it should be replaced by a call to NewServer.
+ // Use a timeoutCtx so that the failed Ping request fails quickly.
+ timeoutCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
+ defer cancel()
+ server, err = libhwk.NewAgentServer(timeoutCtx, mockService, agentDir)
+ require.NoError(t, err)
+ t.Cleanup(server.Stop)
+}
diff --git a/lib/hardwarekey/service.go b/lib/hardwarekey/service.go
new file mode 100644
index 0000000000000..6af7853045ba5
--- /dev/null
+++ b/lib/hardwarekey/service.go
@@ -0,0 +1,41 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package hardwarekey provides common hardware key types and functions.
+// Hardware key types and functions which are not used within /api should be placed here.
+package hardwarekey
+
+import (
+ "context"
+
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekey"
+ "github.com/gravitational/teleport/api/utils/keys/hardwarekeyagent"
+ "github.com/gravitational/teleport/api/utils/keys/piv"
+)
+
+// NewService prepares a new hardware key service. If a running hardware key agent
+// is found, this will return a hardware key agent service with a direct PIV service as backup.
+// Otherwise, the direct PIV service will be returned.
+func NewService(ctx context.Context, prompt hardwarekey.Prompt) hardwarekey.Service {
+ hwks := piv.NewYubiKeyService(prompt)
+
+ agentClient, err := NewAgentClient(ctx, DefaultAgentDir())
+ if err == nil {
+ return hardwarekeyagent.NewService(agentClient, hwks)
+ }
+
+ return hwks
+}
diff --git a/lib/teleterm/config.go b/lib/teleterm/config.go
index 42320f4a17192..3c4fa7d284fda 100644
--- a/lib/teleterm/config.go
+++ b/lib/teleterm/config.go
@@ -49,6 +49,8 @@ type Config struct {
InstallationID string
// AddKeysToAgent is passed to [client.Config].
AddKeysToAgent string
+ // HardwareKeyAgent determines whether the daemon will run the hardware key agent.
+ HardwareKeyAgent bool
}
// CheckAndSetDefaults checks and sets default config values.
diff --git a/lib/teleterm/teleterm.go b/lib/teleterm/teleterm.go
index e721285906a45..75cffb256e238 100644
--- a/lib/teleterm/teleterm.go
+++ b/lib/teleterm/teleterm.go
@@ -32,6 +32,8 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
+ "github.com/gravitational/teleport/api/utils/keys/piv"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
"github.com/gravitational/teleport/lib/teleterm/apiserver"
"github.com/gravitational/teleport/lib/teleterm/clusteridcache"
"github.com/gravitational/teleport/lib/teleterm/clusters"
@@ -99,6 +101,21 @@ func Serve(ctx context.Context, cfg Config) error {
serverAPIWait <- err
}()
+ var hardwareKeyAgentServer *libhwk.Server
+ if cfg.HardwareKeyAgent {
+ hardwareKeyService := piv.NewYubiKeyService(daemonService.NewHardwareKeyPrompt())
+ hardwareKeyAgentServer, err = libhwk.NewAgentServer(ctx, hardwareKeyService, libhwk.DefaultAgentDir())
+ if err != nil {
+ slog.WarnContext(ctx, "failed to create the hardware key agent server", "err", err)
+ } else {
+ go func() {
+ if err := hardwareKeyAgentServer.Serve(ctx); err != nil {
+ slog.WarnContext(ctx, "hardware key agent server error", "err", err)
+ }
+ }()
+ }
+ }
+
// Wait for shutdown signals
go func() {
shutdownSignals := []os.Signal{os.Interrupt, syscall.SIGTERM}
@@ -114,6 +131,10 @@ func Serve(ctx context.Context, cfg Config) error {
daemonService.Stop()
apiServer.Stop()
+
+ if hardwareKeyAgentServer != nil {
+ hardwareKeyAgentServer.Stop()
+ }
}()
errAPI := <-serverAPIWait
diff --git a/tool/tctl/common/config/profile.go b/tool/tctl/common/config/profile.go
index e6252a551ed23..881068a919834 100644
--- a/tool/tctl/common/config/profile.go
+++ b/tool/tctl/common/config/profile.go
@@ -29,10 +29,10 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/types"
- "github.com/gravitational/teleport/api/utils/keys/piv"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/identityfile"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
@@ -46,7 +46,7 @@ func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authcl
proxyAddr = ccf.AuthServerAddr[0]
}
- hwks := piv.NewYubiKeyService(nil /*prompt*/)
+ hwks := libhwk.NewService(ctx, nil /*prompt*/)
clientStore := client.NewFSClientStore(cfg.TeleportHome, client.WithHardwareKeyService(hwks))
if ccf.IdentityFilePath != "" {
var err error
diff --git a/tool/tsh/common/daemon.go b/tool/tsh/common/daemon.go
index dda2baf01c97c..311e7caddc709 100644
--- a/tool/tsh/common/daemon.go
+++ b/tool/tsh/common/daemon.go
@@ -51,6 +51,7 @@ func onDaemonStart(cf *CLIConf) error {
AgentsDir: cf.DaemonAgentsDir,
InstallationID: cf.DaemonInstallationID,
AddKeysToAgent: cf.AddKeysToAgent,
+ HardwareKeyAgent: cf.HardwareKeyAgent,
})
if err != nil {
return trace.Wrap(err)
diff --git a/tool/tsh/common/piv.go b/tool/tsh/common/piv.go
new file mode 100644
index 0000000000000..16d6097e6e7c6
--- /dev/null
+++ b/tool/tsh/common/piv.go
@@ -0,0 +1,59 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/utils/keys/piv"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
+)
+
+type pivCommands struct {
+ agent *pivAgentCommand
+}
+
+func newPIVCommands(app *kingpin.Application) pivCommands {
+ piv := app.Command("piv", "PIV commands.").Hidden()
+ return pivCommands{
+ agent: newPIVAgentCommand(piv),
+ }
+}
+
+// pivAgentCommand implements `tsh piv agent`.
+type pivAgentCommand struct {
+ *kingpin.CmdClause
+}
+
+func newPIVAgentCommand(parent *kingpin.CmdClause) *pivAgentCommand {
+ cmd := &pivAgentCommand{
+ CmdClause: parent.Command("agent", "Start PIV key agent."),
+ }
+ return cmd
+}
+
+func (c *pivAgentCommand) run(cf *CLIConf) error {
+ hwKeyService := piv.NewYubiKeyService(nil /*prompt*/)
+ s, err := libhwk.NewAgentServer(cf.Context, hwKeyService, libhwk.DefaultAgentDir())
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ return s.Serve(cf.Context)
+}
diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go
index f645cd1eb208c..53d93cb6e7e20 100644
--- a/tool/tsh/common/tsh.go
+++ b/tool/tsh/common/tsh.go
@@ -68,7 +68,6 @@ import (
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/wrappers"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
- "github.com/gravitational/teleport/api/utils/keys/piv"
"github.com/gravitational/teleport/api/utils/prompt"
"github.com/gravitational/teleport/lib/asciitable"
"github.com/gravitational/teleport/lib/auth/authclient"
@@ -82,6 +81,7 @@ import (
"github.com/gravitational/teleport/lib/defaults"
dtauthn "github.com/gravitational/teleport/lib/devicetrust/authn"
dtenroll "github.com/gravitational/teleport/lib/devicetrust/enroll"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
"github.com/gravitational/teleport/lib/kube/kubeconfig"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/observability/tracing"
@@ -578,6 +578,9 @@ type CLIConf struct {
// lookPathOverride overrides return of LookPath(). used in tests.
lookPathOverride string
+
+ // HardwareKeyAgent determines whether the daemon will run the hardware key agent.
+ HardwareKeyAgent bool
}
// Stdout returns the stdout writer.
@@ -859,6 +862,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
daemonStart.Flag("kubeconfigs-dir", "Directory containing kubeconfig for Kubernetes Access").StringVar(&cf.DaemonKubeconfigsDir)
daemonStart.Flag("agents-dir", "Directory containing agent config files and data directories for Connect My Computer").StringVar(&cf.DaemonAgentsDir)
daemonStart.Flag("installation-id", "Unique ID identifying a specific Teleport Connect installation").StringVar(&cf.DaemonInstallationID)
+ daemonStart.Flag("hardware-key-agent", "Serve the hardware key agent as part of the daemon process").BoolVar(&cf.HardwareKeyAgent)
daemonStop := daemon.Command("stop", "Gracefully stops a process on Windows by sending Ctrl-Break to it.").Hidden()
daemonStop.Flag("pid", "PID to be stopped").IntVar(&cf.DaemonPid)
@@ -1270,6 +1274,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
vnetUninstallServiceCommand := newVnetUninstallServiceCommand(app)
gitCmd := newGitCommands(app)
+ pivCmd := newPIVCommands(app)
if runtime.GOOS == constants.WindowsOS {
bench.Hidden()
@@ -1669,6 +1674,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = gitCmd.config.run(&cf)
case gitCmd.clone.FullCommand():
err = gitCmd.clone.run(&cf)
+ case pivCmd.agent.FullCommand():
+ err = pivCmd.agent.run(&cf)
default:
// Handle commands that might not be available.
switch {
@@ -4599,7 +4606,7 @@ func setEnvVariables(c *client.Config, options Options) {
}
func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) {
- hwks := piv.NewYubiKeyService(nil /*prompt*/)
+ hwks := libhwk.NewService(cf.Context, nil /*prompt*/)
switch {
case cf.IdentityFileIn != "":
diff --git a/tool/tsh/common/vnet_client_application.go b/tool/tsh/common/vnet_client_application.go
index 87bba66f56729..79d4a61fc330a 100644
--- a/tool/tsh/common/vnet_client_application.go
+++ b/tool/tsh/common/vnet_client_application.go
@@ -28,10 +28,10 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/proto"
- "github.com/gravitational/teleport/api/utils/keys/piv"
vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/clientcache"
+ libhwk "github.com/gravitational/teleport/lib/hardwarekey"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/vnet"
)
@@ -46,7 +46,7 @@ type vnetClientApplication struct {
}
func newVnetClientApplication(cf *CLIConf) (*vnetClientApplication, error) {
- hwks := piv.NewYubiKeyService(nil /*prompt*/)
+ hwks := libhwk.NewService(cf.Context, nil /*prompt*/)
clientStore := client.NewFSClientStore(cf.HomePath, client.WithHardwareKeyService(hwks))
p := &vnetClientApplication{
diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts
index 5607072526168..a2d465e5e75e4 100644
--- a/web/packages/teleterm/src/mainProcess/mainProcess.ts
+++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts
@@ -229,6 +229,9 @@ export default class MainProcess {
`--add-keys-to-agent=${this.configService.get('sshAgent.addKeysToAgent').value}`,
];
+ if (this.configService.get('hardwareKeyAgent.enabled').value) {
+ flags.unshift('--hardware-key-agent');
+ }
if (settings.insecure) {
flags.unshift('--insecure');
}
diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts
index d24d72343fbc9..2c63bf352f44a 100644
--- a/web/packages/teleterm/src/services/config/appConfigSchema.ts
+++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts
@@ -213,6 +213,12 @@ export const createAppConfigSchema = (settings: RuntimeSettings) => {
"'no' never attempts to add them, 'yes' always attempts to add them, " +
"'only' always attempts to add the keys to the agent but it does not save them on disk."
),
+ // Defaults to true for prod, false for dev. Otherwise dev instances would
+ // claim the hardware key agent runner over prod instances by default.
+ 'hardwareKeyAgent.enabled': z
+ .boolean()
+ .default(!settings.dev)
+ .describe('Controls whether the hardware key agent will be started.'),
});
};