MWI: Bound Keypair Rotation (#55240)

* MWI: Bound Keypair Joining: Keypair rotation

This adds keypair rotation for bound keypair rotation. When a rotation
flag is set in the token spec, joining clients will be required to
generate a new keypair and complete an additional joining challenge
against the new keypair.

The flag is a timestamp token to allow for some level of idempotency;
to make setting this flag easier, a new `tctl` command is included:
`tctl bound-keypair request-rotation [token]`. This sets the flag
to the current timestamp, and joining clients will be required to
perform a rotation on their next authentication attempt.

Closes #55084

* Properly initialize the tctl command

* Refactor ClientState to allow storing intermediate state during rotation

* Fix invalid comparison and mutation logic

* Log signature suite and use cryptosuites helper

* Remove outdated TODO

* Frontload MFA check to avoid prompting twice

* Fix tctl command logging

* Fix incomplete docstring

* Fix imports

* Fix typo in log message

* Add tests for server-side rotation

Adjusts the test harness a bit and adds a batch of test cases for
keypair rotation.

Also fixes a lint error.

* Add additional test case for reused keys

* Add ClientState unit test

* Remove unnecessary log

* Fix test lints

* Fix reference to wrong key field

Now that the key can change, fix a dangling reference to the initial
key field. Also s/marshalled/marshaled

* Wrap KeyHistoryEntry in a containing struct

This should allow for some future extension if needed.
This commit is contained in:
Tim Buckley
2025-06-06 03:27:42 +00:00
committed by GitHub
parent 4a185cdc03
commit a552832878
11 changed files with 1094 additions and 206 deletions
+121 -83
View File
@@ -1443,9 +1443,11 @@ func (m *RegisterUsingBoundKeypairCertificates) GetPublicKey() string {
// RegisterUsingBoundKeypairRotationRequest is the response sent by the server
// when a keypair rotation is required.
type RegisterUsingBoundKeypairRotationRequest struct {
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
// The signature algorithm suite in use by the cluster.
SignatureAlgorithmSuite types.SignatureAlgorithmSuite `protobuf:"varint,1,opt,name=signature_algorithm_suite,json=signatureAlgorithmSuite,proto3,enum=types.SignatureAlgorithmSuite" json:"signature_algorithm_suite,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *RegisterUsingBoundKeypairRotationRequest) Reset() {
@@ -1483,6 +1485,13 @@ func (m *RegisterUsingBoundKeypairRotationRequest) XXX_DiscardUnknown() {
var xxx_messageInfo_RegisterUsingBoundKeypairRotationRequest proto.InternalMessageInfo
func (m *RegisterUsingBoundKeypairRotationRequest) GetSignatureAlgorithmSuite() types.SignatureAlgorithmSuite {
if m != nil {
return m.SignatureAlgorithmSuite
}
return types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_UNSPECIFIED
}
// RegisterUsingBoundKeypairMethodResponse is a response sent by the server
// during the bound-keypair joining process. Multiple requests and responses are
// expected to be exchanged during one join process.
@@ -1623,86 +1632,88 @@ func init() {
}
var fileDescriptor_d7e760ce923b836e = []byte{
// 1254 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x58, 0x4b, 0x6f, 0xdb, 0xc6,
0x13, 0x17, 0xe5, 0xd8, 0x89, 0x46, 0xca, 0x43, 0x9b, 0x20, 0x7f, 0x59, 0x88, 0x5f, 0xfc, 0x3b,
0xb1, 0x9c, 0x36, 0x92, 0xab, 0x5e, 0x8a, 0x9c, 0xea, 0x17, 0x20, 0xc7, 0x75, 0x6b, 0x30, 0x29,
0x8a, 0xf6, 0x42, 0xac, 0xa8, 0xad, 0xbc, 0x11, 0x43, 0xb2, 0xbb, 0x2b, 0x03, 0xea, 0xb9, 0x97,
0x7e, 0x80, 0x9c, 0xfb, 0x09, 0x0a, 0xf4, 0xd0, 0x73, 0xcf, 0x3d, 0x14, 0x45, 0x6f, 0xbd, 0x16,
0xfe, 0x0e, 0xbd, 0x17, 0xdc, 0x5d, 0x4a, 0x24, 0x45, 0x4a, 0x32, 0x90, 0xf6, 0x62, 0x99, 0x33,
0xb3, 0xbf, 0x79, 0xcf, 0x2c, 0x09, 0x4d, 0x41, 0x5c, 0x12, 0xf8, 0x4c, 0xb4, 0x5c, 0xd2, 0xc7,
0xce, 0xa8, 0xe5, 0xb8, 0x94, 0x78, 0xa2, 0x15, 0x30, 0x5f, 0xf8, 0xad, 0xd7, 0x3e, 0xf5, 0x38,
0x61, 0x97, 0xd4, 0x21, 0x4d, 0x49, 0x41, 0xcb, 0xf2, 0xa7, 0xde, 0x98, 0x79, 0xcc, 0x21, 0x4c,
0x70, 0x75, 0xa0, 0xbe, 0x95, 0x96, 0x14, 0xa3, 0x80, 0x70, 0xf5, 0x57, 0x89, 0x98, 0x3f, 0x1b,
0xb0, 0x66, 0x91, 0x3e, 0xe5, 0x82, 0xb0, 0xcf, 0x39, 0xf5, 0xfa, 0x27, 0xfb, 0x67, 0x67, 0x44,
0x5c, 0xf8, 0x3d, 0x8b, 0x7c, 0x33, 0x24, 0x5c, 0x20, 0x0c, 0x8f, 0x98, 0x16, 0xb0, 0x87, 0xa1,
0x84, 0x2d, 0xfc, 0x01, 0xf1, 0x6c, 0xa6, 0xf8, 0x35, 0x63, 0xd3, 0x68, 0x94, 0xdb, 0x9b, 0x4d,
0x85, 0x9a, 0xc0, 0x7a, 0x15, 0x0a, 0x6a, 0x1c, 0x6b, 0x95, 0xe5, 0xb1, 0xd0, 0x1e, 0x3c, 0xe0,
0x82, 0xdb, 0xb4, 0x47, 0x3c, 0x41, 0xc5, 0x68, 0x0c, 0x5d, 0xdc, 0x34, 0x1a, 0x15, 0x0b, 0x71,
0xc1, 0x4f, 0x34, 0x4b, 0x9f, 0x30, 0xbb, 0xb0, 0x9e, 0x67, 0x35, 0x0f, 0x7c, 0x8f, 0x13, 0xf4,
0x08, 0x4a, 0xce, 0x05, 0x76, 0x5d, 0xe2, 0xf5, 0x89, 0xb4, 0xb1, 0x64, 0x4d, 0x08, 0xc8, 0x84,
0x65, 0x19, 0x28, 0xa9, 0xa2, 0xdc, 0xae, 0xa8, 0x68, 0x34, 0x0f, 0x43, 0x9a, 0xa5, 0x58, 0xe6,
0x6f, 0x06, 0x6c, 0x24, 0x94, 0xec, 0x7f, 0x3b, 0x64, 0xe4, 0x3f, 0x0f, 0xce, 0xff, 0xe1, 0x36,
0x16, 0x82, 0x70, 0x41, 0x7a, 0x76, 0x0f, 0x0b, 0xac, 0xa3, 0x52, 0x89, 0x88, 0x47, 0x58, 0x60,
0xb4, 0x05, 0x15, 0xec, 0x38, 0x84, 0x73, 0xa5, 0xbf, 0xb6, 0x24, 0x1d, 0x2e, 0x2b, 0x9a, 0x84,
0x33, 0x7b, 0xb0, 0x99, 0xef, 0xcd, 0x3b, 0x0b, 0xda, 0x31, 0xec, 0x24, 0xbd, 0x3c, 0xd7, 0x89,
0x39, 0x8c, 0x60, 0xc6, 0xca, 0xea, 0x70, 0x8b, 0xfb, 0xee, 0x50, 0x50, 0xdf, 0x93, 0xba, 0x2a,
0xd6, 0xf8, 0xd9, 0xfc, 0xdb, 0x80, 0xed, 0x6c, 0x9c, 0x13, 0x8f, 0x0a, 0x8a, 0xdd, 0x28, 0x3a,
0x87, 0x50, 0x09, 0x1b, 0xe5, 0xda, 0x01, 0x2f, 0x87, 0xa7, 0x22, 0x90, 0x55, 0xb8, 0x49, 0x06,
0x76, 0xe8, 0x80, 0x0a, 0x6e, 0xa7, 0x60, 0xad, 0x90, 0x41, 0xe8, 0x17, 0xfa, 0x1f, 0xac, 0x90,
0x81, 0x3d, 0x20, 0x23, 0x19, 0xd2, 0x90, 0xb3, 0x4c, 0x06, 0xa7, 0x64, 0x84, 0x3e, 0x05, 0xa4,
0x32, 0x80, 0x43, 0x83, 0xed, 0x00, 0x33, 0xfc, 0x86, 0xd7, 0x6e, 0x48, 0xf5, 0x1b, 0x3a, 0x32,
0xaf, 0xce, 0xcf, 0xf6, 0x27, 0x32, 0xe7, 0xa1, 0x08, 0x11, 0x84, 0x71, 0xab, 0x8a, 0x53, 0x64,
0x7e, 0x70, 0x03, 0x8a, 0x64, 0x60, 0xfe, 0x9e, 0x6e, 0xc7, 0xb1, 0xdf, 0x91, 0xad, 0xfb, 0x70,
0x83, 0x7a, 0x34, 0x72, 0xf4, 0x3d, 0xad, 0x69, 0x91, 0x58, 0x75, 0x0a, 0x96, 0x3c, 0x8a, 0x6c,
0x40, 0xe3, 0xa4, 0xda, 0x4c, 0xa7, 0x43, 0x27, 0xb5, 0x39, 0x13, 0x70, 0x2a, 0x89, 0x9d, 0x82,
0x55, 0x75, 0xd2, 0xc4, 0x83, 0x12, 0xdc, 0x0c, 0xf0, 0xc8, 0xf5, 0x71, 0xcf, 0xfc, 0xc1, 0x48,
0x75, 0x6a, 0xcc, 0x21, 0x5d, 0x07, 0x9f, 0x40, 0x35, 0x6e, 0x4e, 0x3c, 0x8f, 0x6b, 0x93, 0x40,
0x1e, 0x7b, 0x0e, 0x1b, 0x05, 0x82, 0xf4, 0x0e, 0x19, 0x91, 0xc3, 0x00, 0xbb, 0x9d, 0x82, 0x75,
0x2f, 0xa6, 0x5c, 0xc5, 0x67, 0x7b, 0x46, 0x91, 0x86, 0xd9, 0x93, 0xcc, 0xb8, 0x85, 0x3f, 0x1a,
0x50, 0xcb, 0x4b, 0x14, 0x7a, 0x08, 0x2b, 0xc1, 0xb0, 0xeb, 0x52, 0x47, 0x57, 0xa8, 0x7e, 0x42,
0x1b, 0x50, 0x76, 0x18, 0xc1, 0x82, 0xc4, 0x5b, 0x12, 0x14, 0x49, 0x36, 0xe4, 0x33, 0x40, 0x5a,
0x20, 0x96, 0x6a, 0x55, 0x43, 0x56, 0x55, 0x71, 0x62, 0x1a, 0xd1, 0x2e, 0xdc, 0xd3, 0xe2, 0x9c,
0xf6, 0x3d, 0x2c, 0x86, 0x8c, 0xc8, 0x5a, 0xaa, 0x58, 0x77, 0x15, 0xfd, 0x65, 0x44, 0x36, 0xbf,
0x84, 0x87, 0xd9, 0xe1, 0x40, 0x3b, 0x10, 0x0a, 0xeb, 0x27, 0xbb, 0xeb, 0xfa, 0x5d, 0x6d, 0xf5,
0x9d, 0x09, 0xf9, 0xc0, 0xf5, 0xbb, 0xa1, 0x57, 0x9c, 0x38, 0x8c, 0x44, 0x13, 0x56, 0x3f, 0x99,
0x3f, 0x15, 0xe1, 0xfe, 0x67, 0x0c, 0x3b, 0xae, 0x54, 0x47, 0x62, 0x35, 0x77, 0xf3, 0x82, 0xe0,
0x1e, 0x61, 0xbc, 0x66, 0x6c, 0x2e, 0x35, 0xca, 0xed, 0x1d, 0x1d, 0xd5, 0x0c, 0xe1, 0x66, 0x47,
0x49, 0x1e, 0x7b, 0x82, 0x8d, 0xac, 0xe8, 0x1c, 0xfa, 0x02, 0xee, 0xea, 0x80, 0xdb, 0x11, 0x54,
0x51, 0x42, 0x35, 0x67, 0x40, 0x9d, 0xab, 0x13, 0x09, 0xc4, 0x3b, 0x41, 0x82, 0x58, 0x7f, 0x0e,
0x95, 0x38, 0x1f, 0xdd, 0x83, 0xa5, 0xb0, 0x5b, 0xd5, 0xf0, 0x0a, 0xff, 0x45, 0x0f, 0x60, 0xf9,
0x12, 0xbb, 0x43, 0x55, 0xe1, 0x25, 0x4b, 0x3d, 0x3c, 0x2f, 0x7e, 0x64, 0xd4, 0xf7, 0xe1, 0x7e,
0x86, 0x8a, 0xeb, 0x40, 0x98, 0x7f, 0x1a, 0xa9, 0xb1, 0xaa, 0xfc, 0x48, 0xf6, 0xac, 0xf3, 0x6e,
0xb6, 0x44, 0xa7, 0x30, 0x6b, 0x4f, 0x1c, 0xc2, 0x1d, 0x5f, 0xea, 0x4e, 0xac, 0xcf, 0x72, 0xbb,
0x9e, 0x1f, 0xe0, 0x4e, 0xc1, 0xba, 0xad, 0xce, 0x68, 0x42, 0xd8, 0x17, 0xfa, 0xb4, 0x39, 0x84,
0xad, 0x19, 0x8e, 0xe9, 0xde, 0x5d, 0x9f, 0x5a, 0x18, 0x9d, 0x42, 0x7c, 0x65, 0x2c, 0xd6, 0x8d,
0x00, 0xb7, 0xa2, 0x31, 0x14, 0x4e, 0xc0, 0xe4, 0x06, 0x39, 0xf0, 0x87, 0x5e, 0xef, 0x94, 0x8c,
0x02, 0x4c, 0xd9, 0xbf, 0x31, 0xfc, 0x9b, 0x70, 0x9f, 0x2a, 0x58, 0x5b, 0x82, 0xc5, 0x3a, 0xa3,
0x64, 0x55, 0x35, 0xeb, 0x85, 0x4f, 0xbd, 0x97, 0x92, 0x11, 0xca, 0x07, 0x8c, 0x5c, 0x52, 0x7f,
0xc8, 0xf5, 0x01, 0x81, 0x05, 0x89, 0x5a, 0x3b, 0x62, 0xc9, 0x03, 0x21, 0xc3, 0xec, 0xc0, 0xd3,
0x5c, 0x7f, 0xae, 0xb7, 0x14, 0x5f, 0xc0, 0x6e, 0x2e, 0x92, 0xe5, 0xab, 0x49, 0x32, 0x06, 0x5a,
0x03, 0x50, 0xb3, 0xca, 0x9e, 0xd4, 0x72, 0x49, 0x51, 0x4e, 0xc9, 0xc8, 0xfc, 0xa5, 0x08, 0x4f,
0x72, 0xc1, 0x92, 0xd5, 0x7b, 0x94, 0xd8, 0x38, 0x99, 0x0b, 0x22, 0x3f, 0x47, 0xe3, 0xa5, 0xd3,
0x9d, 0xb1, 0x74, 0x3e, 0x98, 0x87, 0xb9, 0xd8, 0xde, 0x41, 0x36, 0x54, 0x99, 0x8e, 0xc3, 0x44,
0xc5, 0x92, 0x54, 0xb1, 0x37, 0x4f, 0x45, 0x3a, 0x80, 0xe1, 0x72, 0x61, 0x29, 0x5a, 0x7c, 0x6d,
0x60, 0x30, 0xe7, 0x9b, 0x3b, 0x27, 0x0b, 0xc9, 0xfb, 0x56, 0x31, 0x75, 0xdf, 0x32, 0xbf, 0x37,
0xe0, 0x71, 0xbe, 0x0e, 0xc2, 0x04, 0xfd, 0x9a, 0x3a, 0x58, 0x10, 0x3e, 0xb9, 0x99, 0x19, 0xb9,
0x37, 0xb3, 0xd0, 0x94, 0x58, 0xb9, 0xaa, 0xc1, 0x5f, 0x7a, 0x1d, 0x95, 0x69, 0xca, 0xd2, 0xa5,
0x74, 0xbd, 0x3c, 0x85, 0xc6, 0x02, 0xa1, 0x53, 0x93, 0xe3, 0x6d, 0x71, 0x46, 0x0b, 0xa7, 0x06,
0xc8, 0x49, 0x7a, 0x80, 0x94, 0xdb, 0xbb, 0x0b, 0x57, 0x43, 0x72, 0xd6, 0x1c, 0x25, 0x67, 0xcd,
0xfb, 0x73, 0x61, 0x62, 0x11, 0x1c, 0xcf, 0x22, 0x74, 0x06, 0xb7, 0xa2, 0xb4, 0xeb, 0xd2, 0x69,
0x2d, 0x5e, 0x3a, 0x51, 0xc9, 0x8f, 0x21, 0xe2, 0xa3, 0xad, 0xfd, 0x76, 0x19, 0xca, 0x6a, 0x90,
0xc8, 0xb7, 0x3a, 0x44, 0xe1, 0x61, 0xf6, 0x4b, 0x0c, 0xda, 0xce, 0x52, 0x99, 0x7e, 0x33, 0xab,
0x3f, 0x9e, 0x23, 0xa5, 0xd4, 0x36, 0x8c, 0x3d, 0x03, 0xf9, 0x50, 0xcb, 0xbb, 0xfc, 0xa3, 0x27,
0x59, 0x30, 0xd3, 0xef, 0x3a, 0xf5, 0x9d, 0xb9, 0x72, 0x31, 0x85, 0x69, 0xdf, 0xc6, 0xd7, 0xbe,
0x6c, 0xdf, 0xd2, 0xd7, 0xdc, 0x6c, 0xdf, 0xa6, 0xee, 0x8e, 0x52, 0x15, 0x83, 0xd5, 0xdc, 0x45,
0x85, 0x32, 0x8d, 0xce, 0xd8, 0xd1, 0xf5, 0xc6, 0x7c, 0xc1, 0x98, 0xce, 0xef, 0xd2, 0xef, 0x86,
0xd3, 0x25, 0x8e, 0x9e, 0xcd, 0xab, 0x9b, 0xa4, 0x01, 0xcd, 0x45, 0xc5, 0x63, 0x66, 0x1c, 0x01,
0x9a, 0xde, 0x72, 0x68, 0xee, 0x02, 0xac, 0x27, 0x06, 0xc4, 0xc1, 0xc7, 0xbf, 0x5e, 0xad, 0x1b,
0x7f, 0x5c, 0xad, 0x1b, 0x7f, 0x5d, 0xad, 0x1b, 0x5f, 0xb5, 0xfb, 0x54, 0x5c, 0x0c, 0xbb, 0x4d,
0xc7, 0x7f, 0xd3, 0xea, 0x33, 0x7c, 0x49, 0x55, 0x25, 0x63, 0xb7, 0x35, 0xfe, 0x9a, 0x80, 0x03,
0x9a, 0xf8, 0xe8, 0xd0, 0x5d, 0x91, 0x3f, 0x1f, 0xfe, 0x13, 0x00, 0x00, 0xff, 0xff, 0x72, 0x17,
0x9a, 0x35, 0xd2, 0x10, 0x00, 0x00,
// 1294 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x58, 0xcf, 0x8f, 0xd3, 0xc6,
0x17, 0x8f, 0xb3, 0xec, 0x42, 0x5e, 0xc2, 0xc2, 0x0e, 0x08, 0x76, 0x23, 0x08, 0x8b, 0xbf, 0xc0,
0x86, 0x6f, 0x4b, 0x42, 0xd3, 0x4b, 0xc5, 0xa9, 0xd9, 0x5d, 0xa4, 0x2c, 0x74, 0xdb, 0x95, 0xa1,
0xaa, 0xca, 0xc5, 0x9a, 0x38, 0xd3, 0xec, 0x10, 0x63, 0xbb, 0x33, 0xe3, 0x95, 0xd2, 0x73, 0x55,
0xa9, 0x7f, 0x00, 0xe7, 0xfe, 0x05, 0x95, 0x7a, 0xe8, 0xb9, 0xe7, 0x1e, 0xaa, 0xaa, 0xb7, 0x5e,
0x2b, 0xfe, 0x87, 0xde, 0x2b, 0xcf, 0x8c, 0x1d, 0xdb, 0xb1, 0x93, 0x20, 0xd1, 0x5e, 0x58, 0xfc,
0xde, 0x9b, 0xcf, 0xfb, 0xfd, 0xde, 0x4c, 0xa0, 0x23, 0x88, 0x4b, 0x02, 0x9f, 0x89, 0xae, 0x4b,
0xc6, 0xd8, 0x99, 0x76, 0x1d, 0x97, 0x12, 0x4f, 0x74, 0x03, 0xe6, 0x0b, 0xbf, 0xfb, 0xd2, 0xa7,
0x1e, 0x27, 0xec, 0x8c, 0x3a, 0xa4, 0x23, 0x29, 0x68, 0x5d, 0xfe, 0x69, 0xb6, 0x17, 0x1e, 0x73,
0x08, 0x13, 0x5c, 0x1d, 0x68, 0xde, 0xce, 0x4b, 0x8a, 0x69, 0x40, 0xb8, 0xfa, 0x57, 0x89, 0x98,
0x3f, 0x1b, 0x70, 0xd3, 0x22, 0x63, 0xca, 0x05, 0x61, 0x9f, 0x73, 0xea, 0x8d, 0x8f, 0xfa, 0xc7,
0xc7, 0x44, 0x9c, 0xfa, 0x23, 0x8b, 0x7c, 0x1d, 0x12, 0x2e, 0x10, 0x86, 0x1b, 0x4c, 0x0b, 0xd8,
0x61, 0x24, 0x61, 0x0b, 0x7f, 0x42, 0x3c, 0x9b, 0x29, 0xfe, 0xb6, 0xb1, 0x6b, 0xb4, 0xeb, 0xbd,
0xdd, 0x8e, 0x42, 0xcd, 0x60, 0x3d, 0x8f, 0x04, 0x35, 0x8e, 0xb5, 0xc3, 0xca, 0x58, 0xe8, 0x21,
0x5c, 0xe5, 0x82, 0xdb, 0x74, 0x44, 0x3c, 0x41, 0xc5, 0x34, 0x81, 0xae, 0xee, 0x1a, 0xed, 0x86,
0x85, 0xb8, 0xe0, 0x47, 0x9a, 0xa5, 0x4f, 0x98, 0x43, 0x68, 0x95, 0x59, 0xcd, 0x03, 0xdf, 0xe3,
0x04, 0xdd, 0x80, 0x9a, 0x73, 0x8a, 0x5d, 0x97, 0x78, 0x63, 0x22, 0x6d, 0xac, 0x59, 0x33, 0x02,
0x32, 0x61, 0x5d, 0x06, 0x4a, 0xaa, 0xa8, 0xf7, 0x1a, 0x2a, 0x1a, 0x9d, 0x83, 0x88, 0x66, 0x29,
0x96, 0xf9, 0x9b, 0x01, 0xb7, 0x32, 0x4a, 0xfa, 0xdf, 0x84, 0x8c, 0xfc, 0xe7, 0xc1, 0xf9, 0x1f,
0x5c, 0xc4, 0x42, 0x10, 0x2e, 0xc8, 0xc8, 0x1e, 0x61, 0x81, 0x75, 0x54, 0x1a, 0x31, 0xf1, 0x10,
0x0b, 0x8c, 0x6e, 0x43, 0x03, 0x3b, 0x0e, 0xe1, 0x5c, 0xe9, 0xdf, 0x5e, 0x93, 0x0e, 0xd7, 0x15,
0x4d, 0xc2, 0x99, 0x23, 0xd8, 0x2d, 0xf7, 0xe6, 0x9d, 0x05, 0xed, 0x31, 0xec, 0x65, 0xbd, 0x3c,
0xd1, 0x89, 0x39, 0x88, 0x61, 0x12, 0x65, 0x4d, 0xb8, 0xc0, 0x7d, 0x37, 0x14, 0xd4, 0xf7, 0xa4,
0xae, 0x86, 0x95, 0x7c, 0x9b, 0x7f, 0x1b, 0x70, 0xa7, 0x18, 0xe7, 0xc8, 0xa3, 0x82, 0x62, 0x37,
0x8e, 0xce, 0x01, 0x34, 0xa2, 0x46, 0x79, 0xeb, 0x80, 0xd7, 0xa3, 0x53, 0x31, 0xc8, 0x0e, 0x9c,
0x27, 0x13, 0x3b, 0x72, 0x40, 0x05, 0x77, 0x50, 0xb1, 0x36, 0xc8, 0x24, 0xf2, 0x0b, 0x5d, 0x87,
0x0d, 0x32, 0xb1, 0x27, 0x64, 0x2a, 0x43, 0x1a, 0x71, 0xd6, 0xc9, 0xe4, 0x29, 0x99, 0xa2, 0x4f,
0x01, 0xa9, 0x0c, 0xe0, 0xc8, 0x60, 0x3b, 0xc0, 0x0c, 0xbf, 0xe2, 0xdb, 0xe7, 0xa4, 0xfa, 0x5b,
0x3a, 0x32, 0xcf, 0x4f, 0x8e, 0xfb, 0x33, 0x99, 0x93, 0x48, 0x84, 0x08, 0xc2, 0xb8, 0xb5, 0x85,
0x73, 0x64, 0xbe, 0x7f, 0x0e, 0xaa, 0x64, 0x62, 0xfe, 0x9e, 0x6f, 0xc7, 0xc4, 0xef, 0xd8, 0xd6,
0x3e, 0x9c, 0xa3, 0x1e, 0x8d, 0x1d, 0x7d, 0x4f, 0x6b, 0x5a, 0x25, 0x56, 0x83, 0x8a, 0x25, 0x8f,
0x22, 0x1b, 0x50, 0x92, 0x54, 0x9b, 0xe9, 0x74, 0xe8, 0xa4, 0x76, 0x16, 0x02, 0xce, 0x25, 0x71,
0x50, 0xb1, 0xb6, 0x9c, 0x3c, 0x71, 0xbf, 0x06, 0xe7, 0x03, 0x3c, 0x75, 0x7d, 0x3c, 0x32, 0x7f,
0x30, 0x72, 0x9d, 0x9a, 0x72, 0x48, 0xd7, 0xc1, 0x27, 0xb0, 0x95, 0x36, 0x27, 0x9d, 0xc7, 0x9b,
0xb3, 0x40, 0x3e, 0xf6, 0x1c, 0x36, 0x0d, 0x04, 0x19, 0x1d, 0x30, 0x22, 0x87, 0x01, 0x76, 0x07,
0x15, 0xeb, 0x72, 0x4a, 0xb9, 0x8a, 0xcf, 0x9d, 0x05, 0x45, 0x1a, 0x65, 0x4f, 0x32, 0xd3, 0x16,
0xfe, 0x68, 0xc0, 0x76, 0x59, 0xa2, 0xd0, 0x35, 0xd8, 0x08, 0xc2, 0xa1, 0x4b, 0x1d, 0x5d, 0xa1,
0xfa, 0x0b, 0xdd, 0x82, 0xba, 0xc3, 0x08, 0x16, 0x24, 0xdd, 0x92, 0xa0, 0x48, 0xb2, 0x21, 0x1f,
0x00, 0xd2, 0x02, 0xa9, 0x54, 0xab, 0x1a, 0xb2, 0xb6, 0x14, 0x27, 0xa5, 0x11, 0xdd, 0x87, 0xcb,
0x5a, 0x9c, 0xd3, 0xb1, 0x87, 0x45, 0xc8, 0x88, 0xac, 0xa5, 0x86, 0x75, 0x49, 0xd1, 0x9f, 0xc5,
0x64, 0xf3, 0x4b, 0xb8, 0x56, 0x1c, 0x0e, 0xb4, 0x07, 0x91, 0xb0, 0xfe, 0xb2, 0x87, 0xae, 0x3f,
0xd4, 0x56, 0x6f, 0xce, 0xc8, 0xfb, 0xae, 0x3f, 0x8c, 0xbc, 0xe2, 0xc4, 0x61, 0x24, 0x9e, 0xb0,
0xfa, 0xcb, 0xfc, 0xa9, 0x0a, 0x57, 0x3e, 0x63, 0xd8, 0x71, 0xa5, 0x3a, 0x92, 0xaa, 0xb9, 0xf3,
0xa7, 0x04, 0x8f, 0x08, 0xe3, 0xdb, 0xc6, 0xee, 0x5a, 0xbb, 0xde, 0xdb, 0xd3, 0x51, 0x2d, 0x10,
0xee, 0x0c, 0x94, 0xe4, 0x63, 0x4f, 0xb0, 0xa9, 0x15, 0x9f, 0x43, 0x5f, 0xc0, 0x25, 0x1d, 0x70,
0x3b, 0x86, 0xaa, 0x4a, 0xa8, 0xce, 0x02, 0xa8, 0x13, 0x75, 0x22, 0x83, 0xb8, 0x19, 0x64, 0x88,
0xcd, 0x47, 0xd0, 0x48, 0xf3, 0xd1, 0x65, 0x58, 0x8b, 0xba, 0x55, 0x0d, 0xaf, 0xe8, 0xbf, 0xe8,
0x2a, 0xac, 0x9f, 0x61, 0x37, 0x54, 0x15, 0x5e, 0xb3, 0xd4, 0xc7, 0xa3, 0xea, 0x47, 0x46, 0xb3,
0x0f, 0x57, 0x0a, 0x54, 0xbc, 0x0d, 0x84, 0xf9, 0xa7, 0x91, 0x1b, 0xab, 0xca, 0x8f, 0x6c, 0xcf,
0x3a, 0xef, 0x66, 0x4b, 0x0c, 0x2a, 0x8b, 0xf6, 0xc4, 0x01, 0x6c, 0xfa, 0x52, 0x77, 0x66, 0x7d,
0xd6, 0x7b, 0xcd, 0xf2, 0x00, 0x0f, 0x2a, 0xd6, 0x45, 0x75, 0x46, 0x13, 0xa2, 0xbe, 0xd0, 0xa7,
0xcd, 0x10, 0x6e, 0x2f, 0x70, 0x4c, 0xf7, 0x6e, 0x6b, 0x6e, 0x61, 0x0c, 0x2a, 0xe9, 0x95, 0xb1,
0x5a, 0x37, 0x02, 0x5c, 0x88, 0xc7, 0x50, 0x34, 0x01, 0xb3, 0x1b, 0x64, 0xdf, 0x0f, 0xbd, 0xd1,
0x53, 0x32, 0x0d, 0x30, 0x65, 0xff, 0xc6, 0xf0, 0xef, 0xc0, 0x15, 0xaa, 0x60, 0x6d, 0x09, 0x96,
0xea, 0x8c, 0x9a, 0xb5, 0xa5, 0x59, 0x4f, 0x7c, 0xea, 0x3d, 0x93, 0x8c, 0x48, 0x3e, 0x60, 0xe4,
0x8c, 0xfa, 0x21, 0xd7, 0x07, 0x04, 0x16, 0x24, 0x6e, 0xed, 0x98, 0x25, 0x0f, 0x44, 0x0c, 0x73,
0x00, 0xff, 0x2f, 0xf5, 0xe7, 0xed, 0x96, 0xe2, 0x13, 0xb8, 0x5f, 0x8a, 0x64, 0xf9, 0x6a, 0x92,
0x24, 0x40, 0x37, 0x01, 0xd4, 0xac, 0xb2, 0x67, 0xb5, 0x5c, 0x53, 0x94, 0xa7, 0x64, 0x6a, 0xfe,
0x52, 0x85, 0x7b, 0xa5, 0x60, 0xd9, 0xea, 0x3d, 0xcc, 0x6c, 0x9c, 0xc2, 0x05, 0x51, 0x9e, 0xa3,
0x64, 0xe9, 0x0c, 0x17, 0x2c, 0x9d, 0x0f, 0x96, 0x61, 0xae, 0xb6, 0x77, 0x90, 0x0d, 0x5b, 0x4c,
0xc7, 0x61, 0xa6, 0x62, 0x4d, 0xaa, 0x78, 0xb8, 0x4c, 0x45, 0x3e, 0x80, 0xd1, 0x72, 0x61, 0x39,
0x5a, 0x7a, 0x6d, 0x60, 0x30, 0x97, 0x9b, 0xbb, 0x24, 0x0b, 0xd9, 0xfb, 0x56, 0x35, 0x77, 0xdf,
0x32, 0xbf, 0x37, 0xe0, 0x6e, 0xb9, 0x0e, 0xc2, 0x04, 0xfd, 0x8a, 0x3a, 0x58, 0x10, 0x3e, 0xbb,
0x99, 0x19, 0xa5, 0x37, 0xb3, 0xc8, 0x94, 0x54, 0xb9, 0xaa, 0xc1, 0x5f, 0x7b, 0x19, 0x97, 0x69,
0xce, 0xd2, 0xb5, 0x7c, 0xbd, 0x7c, 0x67, 0x40, 0x7b, 0x85, 0xd8, 0xa9, 0x8a, 0x79, 0x01, 0x3b,
0xc9, 0x1a, 0xb3, 0xb1, 0x3b, 0xf6, 0x19, 0x15, 0xa7, 0xaf, 0x6c, 0x1e, 0x52, 0xa1, 0xa6, 0xc4,
0x66, 0xaf, 0xa5, 0x9b, 0x34, 0xd9, 0x6b, 0xfd, 0x58, 0xec, 0x59, 0x24, 0x65, 0x5d, 0xe7, 0xc5,
0x0c, 0xf3, 0x75, 0x75, 0xc1, 0x7c, 0xc8, 0x4d, 0xa7, 0xa3, 0xfc, 0x74, 0xaa, 0xf7, 0xee, 0xaf,
0x5c, 0x6a, 0xd9, 0x41, 0x76, 0x98, 0x1d, 0x64, 0xef, 0x2f, 0x85, 0x49, 0xa5, 0x27, 0x19, 0x74,
0xe8, 0x18, 0x2e, 0xc4, 0x35, 0xa5, 0xeb, 0xb2, 0xbb, 0x7a, 0x5d, 0xc6, 0xfd, 0x94, 0x40, 0xa4,
0xe7, 0x66, 0xef, 0xf5, 0x3a, 0xd4, 0xd5, 0x94, 0x92, 0x4f, 0x46, 0x44, 0xe1, 0x5a, 0xf1, 0x0b,
0x09, 0xdd, 0x29, 0x52, 0x99, 0x7f, 0xf6, 0x35, 0xef, 0x2e, 0x91, 0x52, 0x6a, 0xdb, 0xc6, 0x43,
0x03, 0xf9, 0xb0, 0x5d, 0xf6, 0xb2, 0x40, 0xf7, 0x8a, 0x60, 0xe6, 0x1f, 0x52, 0xcd, 0xbd, 0xa5,
0x72, 0x29, 0x85, 0x79, 0xdf, 0x92, 0x3b, 0x65, 0xb1, 0x6f, 0xf9, 0x3b, 0x74, 0xb1, 0x6f, 0x73,
0x17, 0x53, 0xa9, 0x8a, 0xc1, 0x4e, 0xe9, 0x16, 0x44, 0x85, 0x46, 0x17, 0x5c, 0x00, 0x9a, 0xed,
0xe5, 0x82, 0x29, 0x9d, 0xdf, 0xe6, 0x1f, 0x9e, 0xf3, 0x25, 0x8e, 0x1e, 0x2c, 0xab, 0x9b, 0xac,
0x01, 0x9d, 0x55, 0xc5, 0x53, 0x66, 0x1c, 0x02, 0x9a, 0x5f, 0xa1, 0x68, 0xe9, 0x76, 0x6d, 0x66,
0xa6, 0xcf, 0xfe, 0xc7, 0xbf, 0xbe, 0x69, 0x19, 0x7f, 0xbc, 0x69, 0x19, 0x7f, 0xbd, 0x69, 0x19,
0x2f, 0x7a, 0x63, 0x2a, 0x4e, 0xc3, 0x61, 0xc7, 0xf1, 0x5f, 0x75, 0xc7, 0x0c, 0x9f, 0x51, 0x55,
0xc9, 0xd8, 0xed, 0x26, 0x3f, 0x55, 0xe0, 0x80, 0x66, 0x7e, 0xd1, 0x18, 0x6e, 0xc8, 0x3f, 0x1f,
0xfe, 0x13, 0x00, 0x00, 0xff, 0xff, 0x83, 0x41, 0x97, 0x10, 0x2f, 0x11, 0x00, 0x00,
}
func (m *RegisterUsingIAMMethodRequest) Marshal() (dAtA []byte, err error) {
@@ -2826,6 +2837,11 @@ func (m *RegisterUsingBoundKeypairRotationRequest) MarshalToSizedBuffer(dAtA []b
i -= len(m.XXX_unrecognized)
copy(dAtA[i:], m.XXX_unrecognized)
}
if m.SignatureAlgorithmSuite != 0 {
i = encodeVarintJoinservice(dAtA, i, uint64(m.SignatureAlgorithmSuite))
i--
dAtA[i] = 0x8
}
return len(dAtA) - i, nil
}
@@ -3473,6 +3489,9 @@ func (m *RegisterUsingBoundKeypairRotationRequest) Size() (n int) {
}
var l int
_ = l
if m.SignatureAlgorithmSuite != 0 {
n += 1 + sovJoinservice(uint64(m.SignatureAlgorithmSuite))
}
if m.XXX_unrecognized != nil {
n += len(m.XXX_unrecognized)
}
@@ -6189,6 +6208,25 @@ func (m *RegisterUsingBoundKeypairRotationRequest) Unmarshal(dAtA []byte) error
return fmt.Errorf("proto: RegisterUsingBoundKeypairRotationRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field SignatureAlgorithmSuite", wireType)
}
m.SignatureAlgorithmSuite = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowJoinservice
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.SignatureAlgorithmSuite |= types.SignatureAlgorithmSuite(b&0x7F) << shift
if b < 0x80 {
break
}
}
default:
iNdEx = preIndex
skippy, err := skipJoinservice(dAtA[iNdEx:])
@@ -276,7 +276,8 @@ message RegisterUsingBoundKeypairCertificates {
// RegisterUsingBoundKeypairRotationRequest is the response sent by the server
// when a keypair rotation is required.
message RegisterUsingBoundKeypairRotationRequest {
// This is a marker, no contents at this time.
// The signature algorithm suite in use by the cluster.
types.SignatureAlgorithmSuite signature_algorithm_suite = 1;
}
// RegisterUsingBoundKeypairMethodResponse is a response sent by the server
+286 -29
View File
@@ -20,8 +20,13 @@ package boundkeypair
import (
"context"
"crypto"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
@@ -29,35 +34,60 @@ import (
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/auth/join"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/sshutils"
)
const (
PrivateKeyPath = "id_bkp"
PublicKeyPath = PrivateKeyPath + ".pub"
JoinStatePath = "bkp_state"
PrivateKeyPath = "id_bkp"
PublicKeyPath = PrivateKeyPath + ".pub"
JoinStatePath = "bkp_state"
KeyHistoryPath = "bkp_key_history.json"
KeyHistoryLength = 10
StandardFileWriteMode = 0600
)
// KeyHistoryEntry records a private key and the timestamp it was generated.
type KeyHistoryEntry struct {
// Time is the time this key was inserted into the history.
Time time.Time `json:"time"`
// PrivateKey is the private key, encoded in PEM format.
PrivateKey string `json:"private_key"`
}
// KeyHistory is a collection of `KeyHistoryEntry`.
type KeyHistory struct {
Entries []KeyHistoryEntry `json:"entries"`
}
// ClientState contains state parameters stored on disk needed to complete the
// bound keypair join process.
type ClientState struct {
mu sync.Mutex
fs FS
// PrivateKey is the parsed private key.
PrivateKey *keys.PrivateKey
// PrivateKeyBytes contains the private key bytes. This value should always
// be nonempty.
// PrivateKeyBytes contains the active private key bytes. This value should
// always be nonempty.
PrivateKeyBytes []byte
// PublicKeyBytes contains the public key bytes. This value is not used at
// runtime, and is only set when a public key should be written to disk,
// like on first creation or during rotation. To consistently access the
// public key, use `.PrivateKey.Public()`.
// PublicKeyBytes contains the active public key bytes. This value is not
// used at runtime, and is only set when a public key should be written to
// disk, like on first creation or during rotation. To consistently access
// the public key, use `.PrivateKey.Public()`.
PublicKeyBytes []byte
// JoinStateBytes contains join state bytes. This value will be empty if
// this client has not yet joined.
JoinStateBytes []byte
// KeyHistory records previous keypairs. In the event of a cluster rollback,
// this history will allow clients to rejoin if the cluster requests a
// keypair not currently marked as active.
KeyHistory []KeyHistoryEntry
}
// ToJoinParams creates joining parameters for use with `join.Register()` from
@@ -70,11 +100,28 @@ func (c *ClientState) ToJoinParams(initialJoinSecret string) *join.BoundKeypairP
}
return &join.BoundKeypairParams{
// Note: pass the internal signer because go-jose does type assertions
// on the standard library types.
CurrentKey: c.PrivateKey.Signer,
PreviousJoinState: c.JoinStateBytes,
InitialJoinSecret: initialJoinSecret,
GetSigner: func(pubKey string) (crypto.Signer, error) {
return c.SignerForPublicKey([]byte(pubKey))
},
RequestNewKeypair: func(ctx context.Context, getSuite cryptosuites.GetSuiteFunc) (crypto.Signer, error) {
signer, err := c.GenerateKeypair(ctx, getSuite)
if err != nil {
return nil, trace.Wrap(err)
}
// Make sure to store the intermediate state. We don't want to risk
// losing a private key if an error occurs between here and the end
// of the join process, but also don't want to force
// `GenerateKeypair()` to trigger a `Store()` on every call, so it's
// reasonably done here.
if err := c.Store(ctx); err != nil {
return nil, trace.Wrap(err)
}
return signer, nil
},
}
}
@@ -84,15 +131,26 @@ func (c *ClientState) UpdateFromRegisterResult(result *join.RegisterResult) erro
return trace.BadParameter("register result is missing bound keypair parameters")
}
c.JoinStateBytes = result.BoundKeypair.JoinState
signer, err := c.SignerForPublicKey([]byte(result.BoundKeypair.BoundPublicKey))
if err != nil {
return trace.Wrap(err, "fetching key requested by auth")
}
// TODO: When implementing rotation, use the bound public key value to set
// the current public key.
if err := c.SetActiveKey(signer); err != nil {
return trace.Wrap(err, "setting new active key")
}
// The helpers above may lock the mutex, so don't lock it until we're
// touching fields directly.
c.mu.Lock()
defer c.mu.Unlock()
c.JoinStateBytes = result.BoundKeypair.JoinState
return nil
}
// ToPublicKeyBytes returns the public key bytes in ssh authorized_keys format.
// ToPublicKeyBytes returns the active public key in ssh authorized_keys format.
func (c *ClientState) ToPublicKeyBytes() ([]byte, error) {
sshPubKey, err := ssh.NewPublicKey(c.PrivateKey.Public())
if err != nil {
@@ -102,6 +160,148 @@ func (c *ClientState) ToPublicKeyBytes() ([]byte, error) {
return ssh.MarshalAuthorizedKey(sshPubKey), nil
}
// pubKeyEqual compares the two public keys per their `Equal()` implementation.
func pubKeyEqual(a, b crypto.PublicKey) (bool, error) {
aEq, ok := a.(interface {
Equal(x crypto.PublicKey) bool
})
if !ok {
return false, trace.BadParameter("unsupported key type %T", a)
}
return aEq.Equal(b), nil
}
// SignerForPublicKey attempts to resolve a signer for the given public key
// encoded in authorized_keys format.
func (c *ClientState) SignerForPublicKey(authorizedKeysBytes []byte) (crypto.Signer, error) {
c.mu.Lock()
defer c.mu.Unlock()
desiredPubKey, err := sshutils.CryptoPublicKey(authorizedKeysBytes)
if err != nil {
return nil, trace.Wrap(err)
}
// Check the active key first.
activePubKeyBytes, err := c.ToPublicKeyBytes()
if err != nil {
return nil, trace.Wrap(err)
}
equal, err := pubKeyEqual(desiredPubKey, activePubKeyBytes)
if err != nil {
return nil, trace.Wrap(err)
} else if equal {
// Parse a fresh copy of the key since this will escape the mutex and we
// can't be sure our local copy is thread safe.
key, err := keys.ParsePrivateKey(c.PrivateKeyBytes)
if err != nil {
return nil, trace.Wrap(err)
}
return key.Signer, nil
}
// Otherwise, search through the key history. If a keypair rotation was
// requested an `GenerateKeypair` was called, the new keypair should have
// been inserted at the top of this list.
for _, entry := range c.KeyHistory {
pk, err := keys.ParsePrivateKey([]byte(entry.PrivateKey))
if err != nil {
return nil, trace.Wrap(err)
}
equal, err := pubKeyEqual(desiredPubKey, pk.Signer.Public())
if err != nil {
return nil, trace.Wrap(err)
}
if equal {
return pk.Signer, nil
}
}
return nil, trace.NotFound("no matching key found")
}
// GenerateKeypair generates a new keypair, adds it to the key history, and
// returns the resulting signer signer.
func (c *ClientState) GenerateKeypair(ctx context.Context, getSuite cryptosuites.GetSuiteFunc) (crypto.Signer, error) {
key, err := cryptosuites.GenerateKey(ctx, getSuite, cryptosuites.BoundKeypairJoining)
if err != nil {
return nil, trace.Wrap(err, "generating keypair")
}
privateKeyBytes, err := keys.MarshalPrivateKey(key)
if err != nil {
return nil, trace.Wrap(err, "marshallng private key")
}
// prepend the new key to the top of the list for faster lookup
c.KeyHistory = append([]KeyHistoryEntry{{
Time: time.Now(),
PrivateKey: string(privateKeyBytes),
}}, c.KeyHistory...)
// Trim if necessary.
if len(c.KeyHistory) > KeyHistoryLength {
c.KeyHistory = c.KeyHistory[:min(len(c.KeyHistory), KeyHistoryLength)]
}
sshPubKey, err := ssh.NewPublicKey(key.Public())
if err != nil {
return nil, trace.Wrap(err, "creating ssh public key")
}
slog.InfoContext(ctx, "Generated new keypair", "public_key", string(ssh.MarshalAuthorizedKey(sshPubKey)))
return key, nil
}
// SetActiveKey updates the active keypair to reflect the given signer. Has no
// effect if the active keypair's public key is already equal to the given
// signer's public key, per its `Equals` implementation. Note that
// `StoreClientState` still must be called after this to commit the changes to
// the storage backend.
func (c *ClientState) SetActiveKey(signer crypto.Signer) error {
c.mu.Lock()
defer c.mu.Unlock()
equal, err := pubKeyEqual(signer.Public(), c.PrivateKey.Public())
if err != nil {
return trace.Wrap(err)
}
if equal {
// nothing to do; specified key is already the active key
return nil
}
key, err := keys.NewPrivateKey(signer)
if err != nil {
return trace.Wrap(err)
}
privateKeyBytes, err := keys.MarshalPrivateKey(key.Signer)
if err != nil {
return trace.Wrap(err, "marshallng private key")
}
sshPubKey, err := ssh.NewPublicKey(key.Public())
if err != nil {
return trace.Wrap(err, "creating ssh public key")
}
c.PrivateKey = key
c.PrivateKeyBytes = privateKeyBytes
c.PublicKeyBytes = ssh.MarshalAuthorizedKey(sshPubKey)
slog.InfoContext(context.Background(), "Set new active keypair", "public_key", string(c.PublicKeyBytes))
return nil
}
type FS interface {
Read(ctx context.Context, name string) ([]byte, error)
Write(ctx context.Context, name string, data []byte) error
@@ -133,6 +333,16 @@ func NewStandardFS(parentDir string) FS {
}
}
// parseKeyHistory parses marshaled key history from JSON bytes
func parseKeyHistory(data []byte) (KeyHistory, error) {
var history KeyHistory
if err := json.Unmarshal(data, &history); err != nil {
return KeyHistory{}, trace.Wrap(err)
}
return history, nil
}
// LoadClientState attempts to load bound keypair client state from the given
// filesystem implementation. Callers should expect to handle NotFound errors
// returned here if a private key is not found; this indicates no prior client
@@ -158,46 +368,83 @@ func LoadClientState(ctx context.Context, fs FS) (*ClientState, error) {
return nil, trace.Wrap(err, "parsing private key")
}
return &ClientState{
PrivateKey: pk,
var keyHistory KeyHistory
keyHistoryBytes, err := fs.Read(ctx, KeyHistoryPath)
if trace.IsNotFound(err) {
// No history, this is allowed.
} else if err != nil {
slog.WarnContext(ctx, "unable to read key history, may be unable to recover in the event of a cluster rollback", "error", err)
} else if len(keyHistoryBytes) > 0 {
keyHistory, err = parseKeyHistory(keyHistoryBytes)
if err != nil {
slog.WarnContext(ctx, "unable to parse key history, may be unable to recover in the event of a cluster rollback", "error", err)
}
}
// If the key history is empty, initialize it with just the current key.
if len(keyHistory.Entries) == 0 {
keyHistory.Entries = []KeyHistoryEntry{{
Time: time.Now(),
PrivateKey: string(privateKeyBytes),
}}
}
return &ClientState{
fs: fs,
PrivateKey: pk,
PrivateKeyBytes: privateKeyBytes,
JoinStateBytes: joinStateBytes,
KeyHistory: keyHistory.Entries,
}, nil
}
// StoreClientState writes bound keypair client state to the given filesystem
// wrapper. Public keys and join state will only be written if
func StoreClientState(ctx context.Context, fs FS, state *ClientState) error {
if err := fs.Write(ctx, PrivateKeyPath, state.PrivateKeyBytes); err != nil {
func (c *ClientState) Store(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.fs.Write(ctx, PrivateKeyPath, c.PrivateKeyBytes); err != nil {
return trace.Wrap(err, "writing private key")
}
// TODO: maybe consider just not writing the public key at all. End users
// aren't really meant to look in the internal storage, and we can just
// derive the public key whenever we want.
// Only write the public key if it was explicitly provided. This helps save
// an unnecessary file write.
if len(state.PublicKeyBytes) > 0 {
if err := fs.Write(ctx, PublicKeyPath, state.PublicKeyBytes); err != nil {
if len(c.PublicKeyBytes) > 0 {
if err := c.fs.Write(ctx, PublicKeyPath, c.PublicKeyBytes); err != nil {
return trace.Wrap(err, "writing public key")
}
}
if len(state.JoinStateBytes) > 0 {
if err := fs.Write(ctx, JoinStatePath, state.JoinStateBytes); err != nil {
if len(c.JoinStateBytes) > 0 {
if err := c.fs.Write(ctx, JoinStatePath, c.JoinStateBytes); err != nil {
return trace.Wrap(err, "writing previous join state")
}
}
if len(c.KeyHistory) > 0 {
bytes, err := json.Marshal(KeyHistory{
Entries: c.KeyHistory,
})
if err != nil {
return trace.Wrap(err, "marshaling key key history")
}
if err := c.fs.Write(ctx, KeyHistoryPath, bytes); err != nil {
return trace.Wrap(err, "writing key history")
}
}
slog.DebugContext(ctx, "stored new bound keypair client state")
return nil
}
// NewUnboundClientState creates a new client state that has not yet been bound,
// i.e. a new keypair that has not been registered with Auth, and no prior join
// state.
func NewUnboundClientState(ctx context.Context, getSuite cryptosuites.GetSuiteFunc) (*ClientState, error) {
func NewUnboundClientState(ctx context.Context, fs FS, getSuite cryptosuites.GetSuiteFunc) (*ClientState, error) {
key, err := cryptosuites.GenerateKey(ctx, getSuite, cryptosuites.BoundKeypairJoining)
if err != nil {
return nil, trace.Wrap(err, "generating keypair")
@@ -220,9 +467,19 @@ func NewUnboundClientState(ctx context.Context, getSuite cryptosuites.GetSuiteFu
return nil, trace.Wrap(err)
}
history := []KeyHistoryEntry{
{
Time: time.Now(),
PrivateKey: string(privateKeyBytes),
},
}
return &ClientState{
fs: fs,
PrivateKeyBytes: privateKeyBytes,
PublicKeyBytes: publicKeyBytes,
PrivateKey: pk,
KeyHistory: history,
}, nil
}
@@ -0,0 +1,127 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package boundkeypair
import (
"context"
"testing"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/cryptosuites"
)
// memoryFS is a trivial in memory fs backend for testing use.
type memoryFS struct {
files map[string][]byte
writes uint
}
func (f *memoryFS) Read(ctx context.Context, name string) ([]byte, error) {
data, ok := f.files[name]
if !ok {
return nil, trace.NotFound("not found: %s", name)
}
return data, nil
}
func (f *memoryFS) Write(ctx context.Context, name string, data []byte) error {
f.writes++
f.files[name] = data
return nil
}
func TestClientState(t *testing.T) {
ctx := context.Background()
fs := &memoryFS{
files: map[string][]byte{},
}
getSuite := cryptosuites.StaticAlgorithmSuite(types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1)
state, err := NewUnboundClientState(ctx, fs, getSuite)
require.NoError(t, err)
// Nothing should be written until `Store` is called explicitly
require.Empty(t, fs.files)
require.NoError(t, state.Store(ctx))
require.Len(t, fs.files, 3)
require.Len(t, state.KeyHistory, 1)
require.EqualValues(t, 3, fs.writes)
require.Equal(t, fs.files[PrivateKeyPath], state.PrivateKeyBytes)
// Keep the original key for later.
firstKey := state.PublicKeyBytes
prevKey := firstKey
expectWrites := 3
// Simulate writes up to the key history recording length.
for i := 0; i < KeyHistoryLength-1; i++ {
// We should still be able to load the original signer (< KeyHistoryLength)
_, err := state.SignerForPublicKey(firstKey)
require.NoError(t, err)
// Similarly, the previous key should still be accessible.
_, err = state.SignerForPublicKey(prevKey)
require.NoError(t, err)
prevKey = state.PrivateKey.MarshalSSHPublicKey()
// Generate a new keypair. It should be added to the history, but not marked
// as active.
signer, err := state.GenerateKeypair(ctx, getSuite)
require.NoError(t, err)
require.Len(t, state.KeyHistory, 2+i)
require.EqualValues(t, expectWrites, fs.writes, "no new writes expected")
require.Equal(t, fs.files[PrivateKeyPath], state.PrivateKeyBytes, "active key should not change on generation")
require.NotEqual(t, state.KeyHistory[0].PrivateKey, state.KeyHistory[1].PrivateKey)
// Now explicitly set the new active key
require.NoError(t, state.SetActiveKey(signer))
require.NotEqual(t, state.PublicKeyBytes, prevKey, "public key bytes must change for new key")
require.NoError(t, state.Store(ctx))
expectWrites += 3
// Load a fresh state for the next iteration.
state, err = LoadClientState(ctx, fs)
require.NoError(t, err)
}
// Generate a final keypair. This should push out the initial key. We'll
// have reached the history limit, so the total length should not change.
require.Len(t, state.KeyHistory, 10)
_, err = state.GenerateKeypair(ctx, getSuite)
require.NoError(t, err)
require.Len(t, state.KeyHistory, 10)
require.NoError(t, state.Store(ctx))
state, err = LoadClientState(ctx, fs)
require.NoError(t, err)
// Try to load the original key again; it should fail.
_, err = state.SignerForPublicKey(firstKey)
require.Error(t, err)
}
+45 -33
View File
@@ -85,20 +85,34 @@ type GitlabParams struct {
EnvVarName string
}
// GetSignerFunc is a function that fetches a keypair from bound keypair client
// state.
type GetSignerFunc func(pubKey string) (crypto.Signer, error)
// KeygenFunc is a function to generate a new keypair for bound keypair joining.
// Clients will generally need to store this for future use, so this function
// should include some mechanism for storage and retrieval.
type KeygenFunc func(ctx context.Context, getSuite cryptosuites.GetSuiteFunc) (crypto.Signer, error)
// BoundKeypairParams are parameters specific to bound-keypair joining.
type BoundKeypairParams struct {
// InitialJoinSecret is a one-time-use joining token for use on first join.
// May be unset if a keypair was registered with Auth out of band.
InitialJoinSecret string
// CurrentKey is the keypair currently registered with Auth. On initial
// join using `InitialJoinSecret`, this should be nil in favor of `NewKey`.
CurrentKey crypto.Signer
// PreviousJoinState is the previous join state document provided by Auth
// alongside the previous set of certs. If this is initial registration, it
// can be empty.
PreviousJoinState []byte
// GetSigner is a function that fetches a signer from the client keystore.
GetSigner GetSignerFunc
// RequestNewKeypair is a callback function used to request a new keypair.
// This may be called at initial onboarding when `InitialJoinSecret` is set,
// or on any join (including the initial join) if `RotateAfter` is set on
// the backing token and its value has elapsed.
RequestNewKeypair KeygenFunc
}
// RegisterParams specifies parameters
@@ -947,47 +961,23 @@ func registerUsingBoundKeypairMethod(
) (*RegisterResult, error) {
bkParams := params.BoundKeypairParams
// Build a map of all public keys to signers. At the moment, this is just
// the current key, but may include e.g. previous and next keys for use in
// case of a failed rotation attempt.
// TODO: This implementation is likely to change when rotation is
// implemented.
signers := map[string]crypto.Signer{}
if bkParams.CurrentKey != nil {
pub, err := sshPubKeyFromSigner(bkParams.CurrentKey)
if err != nil {
return nil, trace.Wrap(err, "generating ssh public key from current key signer")
}
signers[pub] = bkParams.CurrentKey
}
initReq := &proto.RegisterUsingBoundKeypairInitialRequest{
JoinRequest: registerUsingTokenRequestForParams(token, hostKeys, params),
InitialJoinSecret: bkParams.InitialJoinSecret,
PreviousJoinState: bkParams.PreviousJoinState,
}
// TODO: When implementing rotation, we should make use of the returned
// public key to ensure that key is marked as the primary.
regResponse, err := client.RegisterUsingBoundKeypairMethod(
ctx,
initReq,
func(resp *proto.RegisterUsingBoundKeypairMethodResponse) (*proto.RegisterUsingBoundKeypairMethodRequest, error) {
switch kind := resp.GetResponse().(type) {
case *proto.RegisterUsingBoundKeypairMethodResponse_Challenge:
// Unlike other join methods, this function may be called multiple
// times to complete challenges using one or both signers, so we'll
// use the passed publicKey hint to resolve the proper signer to
// use.
signer, ok := signers[kind.Challenge.PublicKey]
if !ok {
return nil, trace.NotFound("could not complete challenge for unknown public key: %+#v", kind.Challenge.PublicKey)
signer, err := bkParams.GetSigner(kind.Challenge.PublicKey)
if err != nil {
return nil, trace.Wrap(err, "could not lookup signer for public key %+v", kind.Challenge.PublicKey)
}
// TODO: might not be worth exporting this func; may be cheaper to
// just copy the function here instead.
alg, err := jwt.AlgorithmForPublicKey(signer.Public())
if err != nil {
return nil, trace.Wrap(err, "determining signing algorithm for public key")
@@ -1022,8 +1012,29 @@ func registerUsingBoundKeypairMethod(
},
}, nil
case *proto.RegisterUsingBoundKeypairMethodResponse_Rotation:
// TODO: Follow up implementation
return nil, trace.NotImplemented("keypair rotation not yet implemented")
if bkParams.RequestNewKeypair == nil {
return nil, trace.BadParameter("RequestNewKeypair is required")
}
slog.InfoContext(ctx, "Server has requested keypair rotation", "suite", kind.Rotation.SignatureAlgorithmSuite)
newSigner, err := bkParams.RequestNewKeypair(ctx, cryptosuites.StaticAlgorithmSuite(kind.Rotation.SignatureAlgorithmSuite))
if err != nil {
return nil, trace.Wrap(err, "requesting new keypair")
}
newPubkey, err := sshPubKeyFromSigner(newSigner)
if err != nil {
return nil, trace.Wrap(err)
}
return &proto.RegisterUsingBoundKeypairMethodRequest{
Payload: &proto.RegisterUsingBoundKeypairMethodRequest_RotationResponse{
RotationResponse: &proto.RegisterUsingBoundKeypairRotationResponse{
PublicKey: newPubkey,
},
},
}, nil
default:
// Note: certs variant is handled by RegisterUsingBoundKeypairMethod()
return nil, trace.BadParameter("received unexpected challenge response: %v", resp.GetResponse())
@@ -1033,6 +1044,7 @@ func registerUsingBoundKeypairMethod(
return nil, trace.Wrap(err)
}
// Implementation note, callers are expected to call
return &RegisterResult{
PrivateKey: hostKeys.privateKey,
Certs: regResponse.Certs,
+160 -16
View File
@@ -52,10 +52,6 @@ func validateBoundKeypairTokenSpec(spec *types.ProvisionTokenSpecV2BoundKeypair)
return trace.BadParameter("bound keypair joining experiment is not enabled")
}
if spec.RotateAfter != nil {
return trace.NotImplemented("spec.bound_keypair.rotate_after is not yet implemented")
}
if spec.Onboarding.RegistrationSecret != "" {
return trace.NotImplemented("spec.bound_keypair.onboarding.registration_secret is not yet implemented")
}
@@ -133,10 +129,10 @@ func (a *Server) UpsertBoundKeypairToken(ctx context.Context, token types.Provis
// response.
func (a *Server) issueBoundKeypairChallenge(
ctx context.Context,
marshalledKey string,
marshaledKey string,
challengeResponse client.RegisterUsingBoundKeypairChallengeResponseFunc,
) error {
key, err := libsshutils.CryptoPublicKey([]byte(marshalledKey))
key, err := libsshutils.CryptoPublicKey([]byte(marshaledKey))
if err != nil {
return trace.Wrap(err, "parsing bound public key")
}
@@ -175,7 +171,7 @@ func (a *Server) issueBoundKeypairChallenge(
response, err := challengeResponse(&proto.RegisterUsingBoundKeypairMethodResponse{
Response: &proto.RegisterUsingBoundKeypairMethodResponse_Challenge{
Challenge: &proto.RegisterUsingBoundKeypairChallenge{
PublicKey: marshalledKey,
PublicKey: marshaledKey,
Challenge: string(marshalledChallenge),
},
},
@@ -202,6 +198,98 @@ func (a *Server) issueBoundKeypairChallenge(
return nil
}
// shouldRequestBoundKeypairRotation determines if a keypair rotation should be
// requested given configured token field values.
func shouldRequestBoundKeypairRotation(rotateAfter, lastRotatedAt *time.Time, now time.Time) bool {
if rotateAfter == nil {
// Field not set, nothing to do.
return false
}
if rotateAfter.After(now) {
// We haven't reached the rotation threshold, nothing to do.
return false
}
if lastRotatedAt == nil {
// There has not been a previous rotation, so rotate now.
return true
}
// Otherwise, rotate only if a rotation hasn't already taken place, i.e.
// `lastRotatedAt` is before the requested timestamp
return lastRotatedAt.Before(*rotateAfter)
}
// ensurePublicKeysNotEqual ensures the two public keys, in ssh authorized_keys
// format, are parseable public keys and are not equal to one another. This can
// be used to validate that clients actually provided a new key after receiving
// a rotation request.
func ensurePublicKeysNotEqual(a, b string) error {
aParsed, err := libsshutils.CryptoPublicKey([]byte(a))
if err != nil {
return trace.Wrap(err)
}
aEq, ok := aParsed.(interface {
Equal(x crypto.PublicKey) bool
})
if !ok {
return trace.BadParameter("invalid public key type %T", aParsed)
}
bParsed, err := libsshutils.CryptoPublicKey([]byte(b))
if err != nil {
return trace.Wrap(err)
}
if aEq.Equal(bParsed) {
return trace.BadParameter("public key may not be reused after rotation")
}
return nil
}
// requestBoundKeypairRotation requests that clients generate a new keypair and
// send the public key, then issues a signing challenge to ensure ownership of
// the new key.
func (a *Server) requestBoundKeypairRotation(
ctx context.Context,
challengeResponse client.RegisterUsingBoundKeypairChallengeResponseFunc,
) (string, error) {
cap, err := a.GetAuthPreference(ctx)
if err != nil {
return "", trace.Wrap(err)
}
a.logger.InfoContext(ctx, "requesting bound keypair rotation", "suite", cap.GetSignatureAlgorithmSuite())
// Request a new marshaled public key from the client.
response, err := challengeResponse(&proto.RegisterUsingBoundKeypairMethodResponse{
Response: &proto.RegisterUsingBoundKeypairMethodResponse_Rotation{
Rotation: &proto.RegisterUsingBoundKeypairRotationRequest{
SignatureAlgorithmSuite: cap.GetSignatureAlgorithmSuite(),
},
},
})
if err != nil {
return "", trace.Wrap(err, "requesting a new public key")
}
pubKeyResponse, ok := response.Payload.(*proto.RegisterUsingBoundKeypairMethodRequest_RotationResponse)
if !ok {
return "", trace.BadParameter("client provided unexpected keypair request response type %T", response.Payload)
}
// Issue a challenge against this new key to ensure ownership.
pubKey := pubKeyResponse.RotationResponse.PublicKey
if err := a.issueBoundKeypairChallenge(ctx, pubKey, challengeResponse); err != nil {
return "", trace.Wrap(err, "solving challenge for new public key")
}
return pubKey, nil
}
// boundKeypairStatusMutator is a function called to mutate a bound keypair
// status during a call to PatchProvisionToken(). These functions may be called
// repeatedly if e.g. revision checks fail. To ensure invariants remain in
@@ -266,6 +354,38 @@ func mutateStatusBoundBotInstance(newBotInstance, expectPreviousBotInstance stri
}
}
// mutateStatusLastRotatedAt updates the `status.LastRotatedAt` field to
// indicate a keypair rotation has taken place. It ensures the previous value
// has not changed before performing the update.
func mutateStatusLastRotatedAt(newValue, expectPrevValue *time.Time) boundKeypairStatusMutator {
return func(_ *types.ProvisionTokenSpecV2BoundKeypair, status *types.ProvisionTokenStatusV2BoundKeypair) error {
switch {
case expectPrevValue == nil && status.LastRotatedAt == nil:
// no issue
case expectPrevValue != nil && status.LastRotatedAt == nil:
fallthrough
case expectPrevValue == nil && status.LastRotatedAt != nil:
fallthrough
case !expectPrevValue.Equal(*status.LastRotatedAt):
return trace.AccessDenied("unexpected backend state")
}
status.LastRotatedAt = newValue
return nil
}
}
// formatTimePointer stringifies a *time.Time for logging, but gracefully
// handles nil values.
func formatTimePointer(t *time.Time) string {
if t == nil {
return "nil"
}
return t.String()
}
// RegisterUsingBoundKeypairMethod handles joining requests for the bound
// keypair join method. If successful, returns
func (a *Server) RegisterUsingBoundKeypairMethod(
@@ -308,6 +428,8 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
return nil, trace.BadParameter("specified join token is not for `%s` method", types.JoinMethodBoundKeypair)
}
log := a.logger.With("token", ptv2.GetName())
if ptv2.Status == nil {
ptv2.Status = &types.ProvisionTokenStatusV2{}
}
@@ -363,7 +485,7 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
return nil, trace.AccessDenied("previous join state is required but was not provided")
}
a.logger.DebugContext(ctx, "join state verification required, verifying")
log.DebugContext(ctx, "join state verification required, verifying")
joinState, err = boundkeypair.VerifyJoinState(
ca,
string(req.PreviousJoinState),
@@ -377,7 +499,7 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
// TODO: Once we have token-specific locking, generate a lock; this
// indicates the keypair may have been compromised.
// TODO: Audit log event for this.
a.logger.ErrorContext(ctx, "bound keypair join state verification failed", "error", err)
log.ErrorContext(ctx, "bound keypair join state verification failed", "error", err)
return nil, trace.AccessDenied("join state verification failed")
}
@@ -386,7 +508,7 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
// instance is generated.
req.JoinRequest.PreviousBotInstanceID = joinState.BotInstanceID
a.logger.DebugContext(ctx, "join state verified successfully", "join_state", joinState)
log.DebugContext(ctx, "join state verified successfully", "join_state", joinState)
// Note: we don't verify join state if it isn't expected. This is partly
// to ensure server-side recovery will work if join state desyncs - a
@@ -445,7 +567,7 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
if err := a.issueBoundKeypairChallenge(
ctx,
spec.Onboarding.InitialPublicKey,
status.BoundPublicKey,
challengeResponse,
); err != nil {
return nil, trace.Wrap(err)
@@ -475,7 +597,7 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
expectNewBotInstance = true
default:
a.logger.ErrorContext(
log.ErrorContext(
ctx, "unexpected state",
"has_bound_public_key", hasBoundPublicKey,
"has_bound_bot_instance", hasBoundBotInstance,
@@ -486,10 +608,32 @@ func (a *Server) RegisterUsingBoundKeypairMethod(
return nil, trace.BadParameter("unexpected state")
}
if spec.RotateAfter != nil {
// TODO, to be implemented in a future PR. `boundPublicKey` will need to
// be updated.
return nil, trace.NotImplemented("key rotation not yet supported")
// If we've crossed a keypair rotation threshold, request one now.
now := a.clock.Now()
if shouldRequestBoundKeypairRotation(spec.RotateAfter, status.LastRotatedAt, now) {
log.DebugContext(
ctx, "requesting keypair rotation",
"rotate_after", formatTimePointer(spec.RotateAfter),
"last_rotated_at", formatTimePointer(status.LastRotatedAt),
)
newPubKey, err := a.requestBoundKeypairRotation(ctx, challengeResponse)
if err != nil {
return nil, trace.Wrap(err)
}
// Don't let clients provide the same key again.
if err := ensurePublicKeysNotEqual(boundPublicKey, newPubKey); err != nil {
return nil, trace.Wrap(err)
}
mutators = append(mutators,
mutateStatusBoundPublicKey(newPubKey, boundPublicKey),
mutateStatusLastRotatedAt(&now, status.LastRotatedAt),
)
boundPublicKey = newPubKey
// TODO: Follow up with an audit log event for this.
}
certs, botInstanceID, err := a.generateCertsBot(
+235 -37
View File
@@ -26,6 +26,7 @@ import (
"github.com/go-jose/go-jose/v3/jwt"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/client"
@@ -96,9 +97,13 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
ctx := context.Background()
_, correctPublicKey := testBoundKeypair(t)
_, rotatedPublicKey := testBoundKeypair(t)
_, incorrectPublicKey := testBoundKeypair(t)
srv := newTestTLSServer(t)
clock := clockwork.NewFakeClockAt(time.Now().Round(time.Second).UTC())
startTime := clock.Now()
srv := newTestTLSServer(t, withClock(clock))
auth := srv.Auth()
auth.createBoundKeypairValidator = func(subject, clusterName string, publicKey crypto.PublicKey) (boundKeypairValidator, error) {
return &mockBoundKeypairValidator{
@@ -225,20 +230,55 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
}
}
makeSolver := func(publicKey string) client.RegisterUsingBoundKeypairChallengeResponseFunc {
return func(challenge *proto.RegisterUsingBoundKeypairMethodResponse) (*proto.RegisterUsingBoundKeypairMethodRequest, error) {
type wrappedSolver struct {
rotatedPubKey string
rotationCount uint32
challengeCount uint32
solutions []string
wrapped client.RegisterUsingBoundKeypairChallengeResponseFunc
}
makeSolver := func(initialPubKey string, mutators ...func(s *wrappedSolver)) *wrappedSolver {
wrapper := &wrappedSolver{}
for _, mutator := range mutators {
mutator(wrapper)
}
wrapper.wrapped = func(challenge *proto.RegisterUsingBoundKeypairMethodResponse) (*proto.RegisterUsingBoundKeypairMethodRequest, error) {
switch r := challenge.Response.(type) {
case *proto.RegisterUsingBoundKeypairMethodResponse_Challenge:
if r.Challenge.PublicKey != publicKey {
wrapper.challengeCount++
switch r.Challenge.PublicKey {
case initialPubKey:
case wrapper.rotatedPubKey:
default:
return nil, trace.BadParameter("wrong public key")
}
wrapper.solutions = append(wrapper.solutions, r.Challenge.PublicKey)
return &proto.RegisterUsingBoundKeypairMethodRequest{
Payload: &proto.RegisterUsingBoundKeypairMethodRequest_ChallengeResponse{
ChallengeResponse: &proto.RegisterUsingBoundKeypairChallengeResponse{
// For testing purposes, we'll just reply with the
// public key, to avoid needing to parse the JWT.
Solution: []byte(publicKey),
Solution: []byte(r.Challenge.PublicKey),
},
},
}, nil
case *proto.RegisterUsingBoundKeypairMethodResponse_Rotation:
wrapper.rotationCount++
if wrapper.rotatedPubKey == "" {
return nil, trace.BadParameter("can't generate key")
}
return &proto.RegisterUsingBoundKeypairMethodRequest{
Payload: &proto.RegisterUsingBoundKeypairMethodRequest_RotationResponse{
RotationResponse: &proto.RegisterUsingBoundKeypairRotationResponse{
PublicKey: wrapper.rotatedPubKey,
},
},
}, nil
@@ -246,17 +286,30 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
return nil, trace.BadParameter("invalid response type")
}
}
return wrapper
}
withRotatedPubKey := func(pubKey string) func(s *wrappedSolver) {
return func(s *wrappedSolver) {
s.rotatedPubKey = pubKey
}
}
// Advance the clock a bit. Tests may reference `startTime` for a past
// reference point.
clock.Advance(time.Hour)
tests := []struct {
name string
token types.ProvisionTokenV2
initReq *proto.RegisterUsingBoundKeypairInitialRequest
solver client.RegisterUsingBoundKeypairChallengeResponseFunc
solver *wrappedSolver
assertError require.ErrorAssertionFunc
assertSuccess func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse)
assertError require.ErrorAssertionFunc
assertResponse func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse)
assertSolverState func(t *testing.T, s *wrappedSolver)
}{
{
// no bound key, no bound bot instance, aka initial join without
@@ -268,7 +321,7 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
// join count should be incremented
require.Equal(t, uint32(1), v2.Status.BoundKeypair.RecoveryCount)
require.NotEmpty(t, v2.Status.BoundKeypair.BoundBotInstanceID)
@@ -303,7 +356,7 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
// join count should not be incremented
require.Equal(t, uint32(0), v2.Status.BoundKeypair.RecoveryCount)
},
@@ -340,7 +393,7 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, _ *client.BoundKeypairRegistrationResponse) {
require.Equal(t, uint32(1), v2.Status.BoundKeypair.RecoveryCount)
// Should generate a new bot instance
@@ -383,32 +436,13 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
require.ErrorContains(tt, err, "bot instance mismatch")
},
},
{
// TODO: rotation is not yet implemented.
name: "rotation-requested",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
t := time.Now()
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
v2.Spec.BoundKeypair.RotateAfter = &t
// TODO: test clock?
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey),
assertError: func(tt require.TestingT, err error, i ...interface{}) {
require.Error(tt, err)
require.ErrorContains(tt, err, "key rotation not yet supported")
},
},
{
name: "standard-initial-recovery-success",
token: makeToken(withRecovery("standard", 0, 1, "")),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, uint32(1), v2.Status.BoundKeypair.RecoveryCount)
require.NotNil(t, res)
@@ -421,7 +455,7 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
initReq: makeInitReq(withJoinState(jwtSigner, withToken(withRecovery("standard", 1, 2, "id")))),
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, uint32(2), v2.Status.BoundKeypair.RecoveryCount)
require.NotNil(t, res)
state := parseJoinState(t, res.JoinState)
@@ -502,7 +536,7 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
initReq: makeInitReq(withJoinState(jwtSigner, withToken(withRecovery("relaxed", 1, 0, "id")))),
solver: makeSolver(correctPublicKey),
assertError: require.NoError,
assertSuccess: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, uint32(2), v2.Status.BoundKeypair.RecoveryCount)
require.NotNil(t, res)
@@ -512,28 +546,192 @@ func TestServer_RegisterUsingBoundKeypairMethod(t *testing.T) {
require.Equal(t, v2.Status.BoundKeypair.RecoveryCount, state.RecoverySequence)
},
},
{
// Initial rotation, i.e. `LastRotatedAt` isn't set. This should
// trigger as soon as the `RotateAfter` threshold has been crossed.
name: "first-rotation-success",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
v2.Spec.BoundKeypair.RotateAfter = &startTime
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey, withRotatedPubKey(rotatedPublicKey)),
assertError: require.NoError,
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, rotatedPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Equal(t, rotatedPublicKey, res.BoundPublicKey)
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 2, s.challengeCount)
require.EqualValues(t, 1, s.rotationCount)
require.Equal(t, []string{correctPublicKey, rotatedPublicKey}, s.solutions)
},
},
{
// Initial rotation timestamp hasn't been reached
name: "first-rotation-skipped",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
rotateAfter := clock.Now().Add(time.Minute)
v2.Spec.BoundKeypair.RotateAfter = &rotateAfter
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey, withRotatedPubKey(rotatedPublicKey)),
assertError: require.NoError,
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, correctPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Equal(t, correctPublicKey, res.BoundPublicKey)
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 1, s.challengeCount)
require.EqualValues(t, 0, s.rotationCount)
require.Equal(t, []string{correctPublicKey}, s.solutions)
},
},
{
// This should only trigger after `RotateAfter` has been crossed and
// `LastRotatedAt` isn't after it.
name: "second-rotation-success",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
rotateAfter := startTime.Add(10 * time.Minute)
v2.Spec.BoundKeypair.RotateAfter = &rotateAfter
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
v2.Status.BoundKeypair.LastRotatedAt = &startTime
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey, withRotatedPubKey(rotatedPublicKey)),
assertError: require.NoError,
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, rotatedPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Equal(t, rotatedPublicKey, res.BoundPublicKey)
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 2, s.challengeCount)
require.EqualValues(t, 1, s.rotationCount)
require.Equal(t, []string{correctPublicKey, rotatedPublicKey}, s.solutions)
},
},
{
// We shouldn't try to rotate again if LastRotatedAt is greater than
// RotateAfter.
name: "second-rotation-skipped",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
v2.Spec.BoundKeypair.RotateAfter = &startTime
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
rotatedAt := startTime.Add(10 * time.Minute)
v2.Status.BoundKeypair.LastRotatedAt = &rotatedAt
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey, withRotatedPubKey(rotatedPublicKey)),
assertError: require.NoError,
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, correctPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Equal(t, correctPublicKey, res.BoundPublicKey)
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 1, s.challengeCount)
require.EqualValues(t, 0, s.rotationCount)
require.Equal(t, []string{correctPublicKey}, s.solutions)
},
},
{
// If the client doesn't complete rotation, an error should be
// returned and the key should not change on the server.
name: "rotation-failure",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
v2.Spec.BoundKeypair.RotateAfter = &startTime
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey),
assertError: func(tt require.TestingT, err error, i ...interface{}) {
require.ErrorContains(tt, err, "requesting a new public key")
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 1, s.challengeCount)
require.EqualValues(t, 1, s.rotationCount)
require.Equal(t, []string{correctPublicKey}, s.solutions)
},
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, correctPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Nil(t, res)
},
},
{
name: "rotation-same-key-not-allowed",
token: makeToken(func(v2 *types.ProvisionTokenV2) {
v2.Spec.BoundKeypair.RotateAfter = &startTime
v2.Status.BoundKeypair.BoundPublicKey = correctPublicKey
v2.Status.BoundKeypair.BoundBotInstanceID = "asdf"
}),
initReq: makeInitReq(),
solver: makeSolver(correctPublicKey, withRotatedPubKey(correctPublicKey)),
assertError: func(tt require.TestingT, err error, i ...interface{}) {
require.ErrorContains(tt, err, "public key may not be reused after rotation")
},
assertSolverState: func(t *testing.T, s *wrappedSolver) {
require.EqualValues(t, 2, s.challengeCount)
require.EqualValues(t, 1, s.rotationCount)
// note: the client does complete the challenge for the
// duplicate key, but the attempt will ultimately be rejected
require.Equal(t, []string{correctPublicKey, correctPublicKey}, s.solutions)
},
assertResponse: func(t *testing.T, v2 *types.ProvisionTokenV2, res *client.BoundKeypairRegistrationResponse) {
require.Equal(t, correctPublicKey, v2.Status.BoundKeypair.BoundPublicKey)
require.Nil(t, res)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := types.NewProvisionTokenFromSpecAndStatus(
tt.name, time.Now().Add(time.Minute), tt.token.Spec, tt.token.Status,
tt.name, time.Now().Add(2*time.Hour), tt.token.Spec, tt.token.Status,
)
require.NoError(t, err)
require.NoError(t, auth.CreateToken(ctx, token))
tt.initReq.JoinRequest.Token = tt.name
response, err := auth.RegisterUsingBoundKeypairMethod(ctx, tt.initReq, tt.solver)
response, err := auth.RegisterUsingBoundKeypairMethod(ctx, tt.initReq, tt.solver.wrapped)
tt.assertError(t, err)
if tt.assertSuccess != nil {
if tt.assertResponse != nil {
pt, err := auth.GetToken(ctx, tt.name)
require.NoError(t, err)
ptv2, ok := pt.(*types.ProvisionTokenV2)
require.True(t, ok)
tt.assertSuccess(t, ptv2, response)
tt.assertResponse(t, ptv2, response)
}
if tt.assertSolverState != nil {
tt.assertSolverState(t, tt.solver)
}
})
}
+3 -6
View File
@@ -477,7 +477,6 @@ func botIdentityFromToken(
}
// Only set during bound keypair joining, but used both before and after.
var boundKeypairAdapter boundkeypair.FS
var boundKeypairState *boundkeypair.ClientState
switch params.JoinMethod {
@@ -494,8 +493,8 @@ func botIdentityFromToken(
case types.JoinMethodBoundKeypair:
joinSecret := cfg.Onboarding.BoundKeypair.InitialJoinSecret
boundKeypairAdapter = config.NewBoundkeypairDestinationAdapter(cfg.Storage.Destination)
boundKeypairState, err = boundkeypair.LoadClientState(ctx, boundKeypairAdapter)
adapter := config.NewBoundkeypairDestinationAdapter(cfg.Storage.Destination)
boundKeypairState, err = boundkeypair.LoadClientState(ctx, adapter)
if trace.IsNotFound(err) && joinSecret != "" {
return nil, trace.NotImplemented("no existing client state was found and join secrets are not yet supported")
} else if err != nil {
@@ -515,9 +514,7 @@ func botIdentityFromToken(
return nil, trace.Wrap(err)
}
log.DebugContext(ctx, "updating bound keypair client state")
if err := boundkeypair.StoreClientState(ctx, boundKeypairAdapter, boundKeypairState); err != nil {
if err := boundKeypairState.Store(ctx); err != nil {
return nil, trace.Wrap(err)
}
}
+2 -1
View File
@@ -121,13 +121,14 @@ func onKeypairCreateCommand(ctx context.Context, globals *cli.GlobalArgs, cmd *c
state, err = boundkeypair.NewUnboundClientState(
ctx,
fsAdapter,
getSuiteFromProxy(cmd.ProxyServer, globals.Insecure),
)
if err != nil {
return trace.Wrap(err, "initializing new client state")
}
if err := boundkeypair.StoreClientState(ctx, fsAdapter, state); err != nil {
if err := state.Store(ctx); err != nil {
return trace.Wrap(err, "writing bound keypair state")
}
+112
View File
@@ -0,0 +1,112 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package common
import (
"context"
"errors"
"fmt"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/mfa"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/service/servicecfg"
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
)
type BoundKeypairCommand struct {
token string
requestRotation *kingpin.CmdClause
}
func (c *BoundKeypairCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
cmd := app.Command("bound-keypair", "Manage bound-keypair joining tokens")
c.requestRotation = cmd.Command("request-rotation", "Request a keypair rotation on the next join attempt.")
c.requestRotation.Arg("name", "The name of the token").Required().StringVar(&c.token)
}
func (c *BoundKeypairCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) {
var commandFunc func(ctx context.Context, client *authclient.Client) error
switch cmd {
case c.requestRotation.FullCommand():
commandFunc = c.RequestRotation
default:
return false, nil
}
client, closeFn, err := clientFunc(ctx)
if err != nil {
return false, trace.Wrap(err)
}
err = commandFunc(ctx, client)
closeFn(ctx)
return true, trace.Wrap(err)
}
func (c *BoundKeypairCommand) RequestRotation(ctx context.Context, client *authclient.Client) error {
// Perform MFA checks now since we'll otherwise need to prompt twice.
if _, err := mfa.MFAResponseFromContext(ctx); err == nil {
// Nothing to do.
} else {
mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/)
if err == nil {
ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse)
} else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) {
return trace.Wrap(err)
}
}
token, err := client.GetToken(ctx, c.token)
if err != nil {
return trace.Wrap(err)
}
if token.GetJoinMethod() != types.JoinMethodBoundKeypair {
return trace.BadParameter(
"token %s is of type %s, not %s",
c.token, token.GetJoinMethod(), types.JoinMethodBoundKeypair,
)
}
v2, ok := token.(*types.ProvisionTokenV2)
if !ok {
return trace.BadParameter("unsupported token type %T", token)
}
now := time.Now()
v2.Spec.BoundKeypair.RotateAfter = &now
if err := client.UpsertToken(ctx, v2); err != nil {
return trace.Wrap(err)
}
fmt.Printf("Token rotation flag has been set, rotation will be required during the next authentication attempt.\n")
return nil
}
+1
View File
@@ -72,5 +72,6 @@ func Commands() []CLICommand {
&AutoUpdateCommand{},
&stableunixusers.Command{},
&decision.Command{},
&BoundKeypairCommand{},
}
}