Skip to content

Commit 9610ef9

Browse files
committed
[v18] feat: bound keypair support in new join service
Backport #58963 to branch/v18
1 parent 5447f37 commit 9610ef9

File tree

18 files changed

+2086
-408
lines changed

18 files changed

+2086
-408
lines changed

api/gen/proto/go/teleport/join/v1/joinservice.pb.go

Lines changed: 647 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/proto/teleport/join/v1/joinservice.proto

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,104 @@ message TokenInit {
107107
ClientParams client_params = 1;
108108
}
109109

110+
// BoundKeypairInit is sent from the client in response to the ServerInit
111+
// message for the bound keypair join method.
112+
// The server is expected to respond with a BoundKeypairChallenge.
113+
//
114+
// The bound keypair method join flow is:
115+
// 1. client->server: ClientInit
116+
// 2. server->client: ServerInit
117+
// 3. client->server: BoundKeypairInit
118+
// 4. server->client: BoundKeypairChallenge
119+
// 5. client->server: BoundKeypairChallengeSolution
120+
// (optional additional steps if keypair rotation is required)
121+
// server->client: BoundKeypairRotationRequest
122+
// client->server: BoundKeypairRotationResponse
123+
// server->client: BoundKeypairChallenge
124+
// client->server: BoundKeypairChallengeSolution
125+
// 6. server->client: Result containing BoundKeypairResult
126+
message BoundKeypairInit {
127+
// ClientParams holds parameters for the specific type of client trying to join.
128+
ClientParams client_params = 1;
129+
// If set, attempts to bind a new keypair using an initial join secret.
130+
// Any value set here will be ignored if a keypair is already bound.
131+
string initial_join_secret = 2;
132+
// A document signed by Auth containing join state parameters from the
133+
// previous join attempt. Not required on initial join; required on all
134+
// subsequent joins.
135+
bytes previous_join_state = 3;
136+
}
137+
138+
// BoundKeypairChallenge is a challenge issued by the server that joining
139+
// clients are expected to complete.
140+
// The client is expected to respond with a BoundKeypairChallengeSolution.
141+
message BoundKeypairChallenge {
142+
// The desired public key corresponding to the private key that should be used
143+
// to sign this challenge, in SSH authorized keys format.
144+
bytes public_key = 1;
145+
// A challenge to sign with the requested public key. During keypair rotation,
146+
// a second challenge will be provided to verify the new keypair before certs
147+
// are returned.
148+
string challenge = 2;
149+
}
150+
151+
// BoundKeypairChallengeSolution is sent from the client in response to the
152+
// BoundKeypairChallenge.
153+
// The server is expected to respond with either a Result or a
154+
// BoundKeypairRotationRequest.
155+
message BoundKeypairChallengeSolution {
156+
// A solution to a challenge from the server. This generated by signing the
157+
// challenge as a JWT using the keypair associated with the requested public
158+
// key.
159+
bytes solution = 1;
160+
}
161+
162+
// BoundKeypairRotationRequest is sent by the server in response to a
163+
// BoundKeypairChallenge when a keypair rotation is required. It acts like an
164+
// additional challenge, the client is expected to respond with a
165+
// BoundKeypairRotationResponse.
166+
message BoundKeypairRotationRequest {
167+
// The signature algorithm suite in use by the cluster.
168+
string signature_algorithm_suite = 1;
169+
}
170+
171+
// BoundKeypairRotationResponse is sent by the client in response to a
172+
// BoundKeypairRotationRequest from the server.
173+
// The server is expected to respond with an additional BoundKeypairChallenge
174+
// for the new key.
175+
message BoundKeypairRotationResponse {
176+
// The public key to be registered with auth. Clients should expect a
177+
// subsequent challenge against this public key to be sent. This is encoded in
178+
// SSH authorized keys format.
179+
bytes public_key = 1;
180+
}
181+
182+
// BoundKeypairResult holds additional result parameters relevant to the bound
183+
// keypair join method.
184+
message BoundKeypairResult {
185+
// A signed join state document to be provided on the next join attempt.
186+
bytes join_state = 2;
187+
// The public key registered with Auth at the end of the joining ceremony.
188+
// After a successful keypair rotation, this should reflect the newly
189+
// registered public key. This is encoded in SSH authorized keys format.
190+
bytes public_key = 3;
191+
}
192+
193+
// ChallengeSolution holds a solution to a challenge issued by the server.
194+
message ChallengeSolution {
195+
oneof payload {
196+
BoundKeypairChallengeSolution bound_keypair_challenge_solution = 1;
197+
BoundKeypairRotationResponse bound_keypair_rotation_response = 2;
198+
}
199+
}
200+
110201
// JoinRequest is the message type sent from the joining client to the server.
111202
message JoinRequest {
112203
oneof payload {
113204
ClientInit client_init = 1;
114205
TokenInit token_init = 2;
206+
BoundKeypairInit bound_keypair_init = 3;
207+
ChallengeSolution solution = 4;
115208
}
116209
}
117210

@@ -126,7 +219,12 @@ message ServerInit {
126219
}
127220

128221
// Challenge is a challenge message sent from the server that the client must solve.
129-
message Challenge {}
222+
message Challenge {
223+
oneof payload {
224+
BoundKeypairChallenge bound_keypair_challenge = 1;
225+
BoundKeypairRotationRequest bound_keypair_rotation_request = 2;
226+
}
227+
}
130228

131229
// Result is the final message sent from the cluster back to the client, it
132230
// contains the result of the joining process including the assigned host ID
@@ -164,6 +262,8 @@ message HostResult {
164262
message BotResult {
165263
// Certificates holds issued certificates and cluster CAs.
166264
Certificates certificates = 1;
265+
// BoundKeypairResult holds extra result parameters relevant to the bound keypair join method.
266+
optional BoundKeypairResult bound_keypair_result = 2;
167267
}
168268

169269
// JoinResponse is the message type sent from the server to the joining client.

lib/auth/auth.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import (
116116
"github.com/gravitational/teleport/lib/integrations/awsra/createsession"
117117
"github.com/gravitational/teleport/lib/inventory"
118118
iterstream "github.com/gravitational/teleport/lib/itertools/stream"
119+
joinboundkeypair "github.com/gravitational/teleport/lib/join/boundkeypair"
119120
kubetoken "github.com/gravitational/teleport/lib/kube/token"
120121
"github.com/gravitational/teleport/lib/limiter"
121122
"github.com/gravitational/teleport/lib/loginrule"
@@ -777,7 +778,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (as *Server, err error) {
777778
}
778779

779780
if as.createBoundKeypairValidator == nil {
780-
as.createBoundKeypairValidator = func(subject, clusterName string, publicKey crypto.PublicKey) (boundKeypairValidator, error) {
781+
as.createBoundKeypairValidator = func(subject, clusterName string, publicKey crypto.PublicKey) (joinboundkeypair.BoundKeypairValidator, error) {
781782
return boundkeypair.NewChallengeValidator(subject, clusterName, publicKey)
782783
}
783784
}
@@ -1241,7 +1242,7 @@ type Server struct {
12411242

12421243
// createBoundKeypairValidator is a helper to create new bound keypair
12431244
// challenge validators. Used to override the implementation used in tests.
1244-
createBoundKeypairValidator createBoundKeypairValidator
1245+
createBoundKeypairValidator joinboundkeypair.CreateBoundKeypairValidator
12451246

12461247
// loadAllCAs tells tsh to load the host CAs for all clusters when trying to ssh into a node.
12471248
loadAllCAs bool
@@ -1450,6 +1451,12 @@ func (a *Server) SetLockWatcher(lockWatcher *services.LockWatcher) {
14501451
a.lockWatcher = lockWatcher
14511452
}
14521453

1454+
// CheckLockInForce returns an AccessDenied error if there is a lock in force
1455+
// matching at least one of the targets.
1456+
func (a *Server) CheckLockInForce(mode constants.LockingMode, targets []types.LockTarget) error {
1457+
return a.checkLockInForce(mode, targets)
1458+
}
1459+
14531460
func (a *Server) checkLockInForce(mode constants.LockingMode, targets []types.LockTarget) error {
14541461
a.lock.RLock()
14551462
defer a.lock.RUnlock()

lib/auth/bound_keypair_tokens.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Teleport
2+
// Copyright (C) 2025 Gravitational, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package auth
18+
19+
import (
20+
"context"
21+
22+
"github.com/gravitational/trace"
23+
24+
"github.com/gravitational/teleport/api/types"
25+
"github.com/gravitational/teleport/lib/defaults"
26+
"github.com/gravitational/teleport/lib/utils"
27+
)
28+
29+
// validateBoundKeypairTokenSpec performs some basic validation checks on a
30+
// bound_keypair-type join token.
31+
func validateBoundKeypairTokenSpec(spec *types.ProvisionTokenSpecV2BoundKeypair) error {
32+
if spec.Recovery == nil {
33+
return trace.BadParameter("spec.bound_keypair.recovery: field is required")
34+
}
35+
36+
return nil
37+
}
38+
39+
// populateRegistrationSecret populates the
40+
// `status.BoundKeypair.RegistrationSecret` field of a bound keypair token. It
41+
// should be called as part of any token creation or update to ensure the
42+
// registration secret is made available if needed.
43+
func populateRegistrationSecret(v2 *types.ProvisionTokenV2) error {
44+
if v2.GetJoinMethod() != types.JoinMethodBoundKeypair {
45+
return trace.BadParameter("must be called with a bound keypair token")
46+
}
47+
48+
if v2.Spec.BoundKeypair == nil {
49+
v2.Spec.BoundKeypair = &types.ProvisionTokenSpecV2BoundKeypair{}
50+
}
51+
52+
if v2.Status == nil {
53+
v2.Status = &types.ProvisionTokenStatusV2{}
54+
}
55+
if v2.Status.BoundKeypair == nil {
56+
v2.Status.BoundKeypair = &types.ProvisionTokenStatusV2BoundKeypair{}
57+
}
58+
if v2.Spec.BoundKeypair.Onboarding == nil {
59+
v2.Spec.BoundKeypair.Onboarding = &types.ProvisionTokenSpecV2BoundKeypair_OnboardingSpec{}
60+
}
61+
62+
spec := v2.Spec.BoundKeypair
63+
status := v2.Status.BoundKeypair
64+
65+
if status.BoundPublicKey != "" || spec.Onboarding.InitialPublicKey != "" {
66+
// A key has already been bound or preregistered, nothing to do.
67+
return nil
68+
}
69+
70+
if status.RegistrationSecret != "" {
71+
// A secret has already been generated, nothing to do.
72+
return nil
73+
}
74+
75+
if spec.Onboarding.RegistrationSecret != "" {
76+
// An explicit registration secret was provided, so copy it to status.
77+
status.RegistrationSecret = spec.Onboarding.RegistrationSecret
78+
return nil
79+
}
80+
81+
// Otherwise, we have no key and no secret, so generate one now.
82+
s, err := utils.CryptoRandomHex(defaults.TokenLenBytes)
83+
if err != nil {
84+
return trace.Wrap(err)
85+
}
86+
87+
status.RegistrationSecret = s
88+
return nil
89+
}
90+
91+
func (a *Server) CreateBoundKeypairToken(ctx context.Context, token types.ProvisionToken) error {
92+
if token.GetJoinMethod() != types.JoinMethodBoundKeypair {
93+
return trace.BadParameter("must be called with a bound keypair token")
94+
}
95+
96+
tokenV2, ok := token.(*types.ProvisionTokenV2)
97+
if !ok {
98+
return trace.BadParameter("%v join method requires ProvisionTokenV2", types.JoinMethodOracle)
99+
}
100+
101+
spec := tokenV2.Spec.BoundKeypair
102+
if spec == nil {
103+
return trace.BadParameter("bound_keypair token requires non-nil spec.bound_keypair")
104+
}
105+
106+
if err := validateBoundKeypairTokenSpec(spec); err != nil {
107+
return trace.Wrap(err)
108+
}
109+
110+
// Not as much to do here - ideally we'd like to prevent users from
111+
// tampering with the status field, but we don't have a good mechanism to
112+
// stop that that wouldn't also break backup and restore. For now, it's
113+
// simpler and easier to just tell users not to edit those fields.
114+
115+
if err := populateRegistrationSecret(tokenV2); err != nil {
116+
return trace.Wrap(err)
117+
}
118+
119+
return trace.Wrap(a.CreateToken(ctx, tokenV2))
120+
}
121+
122+
func (a *Server) UpsertBoundKeypairToken(ctx context.Context, token types.ProvisionToken) error {
123+
if token.GetJoinMethod() != types.JoinMethodBoundKeypair {
124+
return trace.BadParameter("must be called with a bound keypair token")
125+
}
126+
127+
tokenV2, ok := token.(*types.ProvisionTokenV2)
128+
if !ok {
129+
return trace.BadParameter("%v join method requires ProvisionTokenV2", types.JoinMethodOracle)
130+
}
131+
132+
spec := tokenV2.Spec.BoundKeypair
133+
if spec == nil {
134+
return trace.BadParameter("bound_keypair token requires non-nil spec.bound_keypair")
135+
}
136+
137+
if err := validateBoundKeypairTokenSpec(spec); err != nil {
138+
return trace.Wrap(err)
139+
}
140+
141+
if err := populateRegistrationSecret(tokenV2); err != nil {
142+
return trace.Wrap(err)
143+
}
144+
145+
// Implementation note: checkAndSetDefaults() impl for this token type is
146+
// called at insertion time as part of `tokenToItem()`
147+
return trace.Wrap(a.UpsertToken(ctx, token))
148+
}

lib/auth/export_test.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ package auth
1818

1919
import (
2020
"context"
21-
"crypto"
2221
"crypto/x509"
2322
"crypto/x509/pkix"
2423
"encoding/asn1"
@@ -46,6 +45,7 @@ import (
4645
"github.com/gravitational/teleport/lib/circleci"
4746
"github.com/gravitational/teleport/lib/events"
4847
"github.com/gravitational/teleport/lib/inventory"
48+
"github.com/gravitational/teleport/lib/join/boundkeypair"
4949
"github.com/gravitational/teleport/lib/services"
5050
"github.com/gravitational/teleport/lib/tpm"
5151
"github.com/gravitational/teleport/lib/utils"
@@ -249,14 +249,8 @@ func (a *Server) SetGHAIDTokenJWKSValidator(validator ghaIDTokenJWKSValidator) {
249249
a.ghaIDTokenJWKSValidator = validator
250250
}
251251

252-
type BoundKeypairValidator = boundKeypairValidator
253-
254-
type CreateBoundKeypairValidator func(subject string, clusterName string, publicKey crypto.PublicKey) (BoundKeypairValidator, error)
255-
256-
func (a *Server) SetCreateBoundKeypairValidator(validator CreateBoundKeypairValidator) {
257-
a.createBoundKeypairValidator = func(subject, clusterName string, publicKey crypto.PublicKey) (boundKeypairValidator, error) {
258-
return validator(subject, clusterName, publicKey)
259-
}
252+
func (a *Server) SetCreateBoundKeypairValidator(validator boundkeypair.CreateBoundKeypairValidator) {
253+
a.createBoundKeypairValidator = validator
260254
}
261255

262256
func (a *Server) AuthenticateUserLogin(ctx context.Context, req authclient.AuthenticateUserRequest) (services.UserState, services.AccessChecker, error) {

lib/auth/grpcserver.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5765,6 +5765,7 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
57655765
joinv1.RegisterJoinServiceServer(server, join.NewServer(&join.ServerConfig{
57665766
Authorizer: cfg.Authorizer,
57675767
AuthService: cfg.AuthServer,
5768+
Clock: cfg.AuthServer.clock,
57685769
}))
57695770

57705771
integrationServiceServer, err := integrationv1.NewService(&integrationv1.ServiceConfig{
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Teleport
2+
// Copyright (C) 2025 Gravitational, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package auth
18+
19+
import (
20+
"context"
21+
22+
"github.com/gravitational/teleport/api/client"
23+
"github.com/gravitational/teleport/api/client/proto"
24+
"github.com/gravitational/teleport/lib/join"
25+
)
26+
27+
// RegisterUsingBoundKeypairMethod handles bound keypair joining for the legacy
28+
// join service and accepts the legacy protobuf message types. It calls into
29+
// the common logic implemented in lib/join.
30+
//
31+
// TODO(nklaassen): DELETE IN 20 when removing the legacy join service.
32+
func (a *Server) RegisterUsingBoundKeypairMethod(
33+
ctx context.Context,
34+
req *proto.RegisterUsingBoundKeypairInitialRequest,
35+
challengeResponse client.RegisterUsingBoundKeypairChallengeResponseFunc,
36+
) (*client.BoundKeypairRegistrationResponse, error) {
37+
return join.AdaptRegisterUsingBoundKeypairMethod(ctx, a, a.createBoundKeypairValidator, req, challengeResponse)
38+
}

0 commit comments

Comments
 (0)