Files
teleport/lib/boundkeypair/join_state.go
T
Tim Buckley edabc9f89c MWI Scopes[4]: Scoped bot joining and bound keypair support (#65366)
* MWI: Support scoped join tokens in the bound keypair join method

* Bot fields for scoped tokens

* Check bot scope

* Allow bound keypair tokens to pass scope validation

* Support bots

* Fix outstanding references to ProvisionTokenV2

* Zero check on MustRegisterBefore due to proto impl differences

* Properly pass through nil must_register_before

* Also fix nil timestamps in bound keypair status

* And fix rotate_after

* Add BotInternal certificate field

* Add 'bot' TokenUsageMode value and require for bot joins

* Fix mutator execution for scoped bound keypair joining

* Implement more ScopedToken StrongValidateToken checks for bots

Adds a few more of the bot-specific RFD validation checks for bots.

* Improve bot join checks for scoped joining

* Fix build after rebase and add additional RFD checks

This fixes builds after upstream updates and implements a few more
validations required by the RFD:
- AssignedScope field for bots must be empty
- Moved original AssignedScope check to validateNonBot() since it was
  checked unconditionally before.
- BotScope must be a presumably-valid scope at creation time

* Use scope-aware access checker for initial bot certs

* Fix WeakValidateToken() for scoped tokens for bots

WeakValidateToken() was not updated to match the new checks in
StrongValidateToken(); this adds minimal bot-related exemptions
around `assigned_scope` for bots to allow it to be read without
error.

* Add tests for scoped bot tokens, fix role bug

This adds a set of additional token tests for scoped bot tokens, and
fixes an issue where WeakValidateToken() was erroneously rejecting
invalid system roles.

* Properly initialize bound keypair scoped tokens on creation/upsert

We weren't applying the same initialization logic for scoped tokens
that we did for ProvisionTokenV2; this is required for registration
secrets to work properly.

* Update CRDs

* Fix failing test

* Specify types for bound keypair timestamp fields

* Update terraform resources

* Fix lint

* Include bot scope in BotJoin audit events

This includes the bot scope in audit events. It includes the user
label bot scope for successful joins where the user has been fetched,
and otherwise includes the bot scope configured on the token.

* Reject `token` join method for scoped bot tokens

Bot joining should use `bound_keypair`, so reject scoped bot tokens
that attempt to join with the traditional `token` join method.

* Fix failing test due to BotInternal cert parameter

TestRegisterBotInstance wasn't updated to include the BotInternal
flag on initial identity, so this includes it.

* Actually include Scope in bot audit events

* Mutate correct token status in `patchToken`

The mutate result was discarded because the wrong status object was
passed to `mutateScopedToken`.

* Use ScopedAuthorizer in join/Server.authenticate()

`authenticate()` currently rejects scoped identities which prevented
clients from reauthenticating with an existing identity, which is
important for bot renewal and bound keypair joining.

* Pass `BoundHostID` through properly in `GetBoundKeypairStatus()`

* Add basic TestJoinBoundKeypair_ScopedToken test

* Fix lint

* Update join server comments, add note about feature flag edge case

* Target correct provision token for standard token mutations

* Return correct mutated token in patchToken()

* Hide bound keypair registration secrets when `WithSecrets` is unset

* Avoid possible nil pointer dereference

* Address proto comment suggestion from review

* Deduplicate bound keypair mutator validation logic

Validation logic has been pulled out into a `validate()` interface
function and unified for both token types; scoped token bound keypair
fields are converted to their equivalent ProvisionTokenV2 variant
using existing helper logic.

* Nil check in consumeRecoveryMutator.validate()

* Simplify lastRotatedAtMutator

This removes the confusing switch with fallthroughs in favor of 2
simple ifs and a helper function.

* Ensure bound keypair status is non-nil before mutating

* Note non-nil requirement for new lastRotatedAt value

* Update generated operator resources

* Also nil-check status for standard tokens

* Mirror secret censoring for bound keypair secrets in tctl

This mirrors the existing secret censoring in tctl. It doesn't
actually work since the conditional can never be true.

* Use StrongValidate() on BotScope in token StrongValidate()

`validateBotToken()` was incorrectly calling `scopes.WeakValidate()`
on the bot scope. This changes it to call `scopes.StrongValidate()`
instead.

* Mark scoped token bound keypair registration_secret field as sensitive

* Make lastRotatedAtMutator's `mutateScopedToken` nil-safe

* Tag mutator validation errors with a unique name of the mutator

Errors are tagged with the primary field that mutator modifies to
help identify the failing mutator without necessarily identifying
the specific failing check.

* Address code review feedback

* Wait for ScopedRoleAssignment to become available

SRAs don't always propagate immediately, so adds a
`require.EventuallyWithT()` check to ensure it exists before
continuing.

* Add test for scoped bot joining via kubernetes

* Fix formatting
2026-04-16 02:06:24 +00:00

262 lines
9.0 KiB
Go

// 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 (
"crypto"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/join/provision"
libjwt "github.com/gravitational/teleport/lib/jwt"
)
// JoinState is a signed JWT stored on joining clients alongside their usual
// certificate bundle, used as an additional layer of verification for
// subsequent join attempts.
type JoinState struct {
*jwt.Claims
// BotInstanceID is the bot instance ID associated with this join state when
// it was generated. A new bot instance may be created during the joining
// process if the previous instance expired. The
// `(bot_instance_id, recovery_sequence)` tuple is considered functionally
// unique to each issued join state for verification purposes, and will
// remain unchanged until the next successful recovery. Mutually exclusive
// with HostID.
BotInstanceID string `json:"bot_instance_id"`
// HostID is the unique identifier for standard Teleport (non-bot) agents.
// It is mutually exclusive with `bot_instance_id`, but otherwise behaves
// similarly.
HostID string `json:"host_id"`
// RecoverySequence is the recovery sequence number. This is incremented
// each time a recovery is performed, including on first join. This counter
// is not reset if a new bot instance is generated.
RecoverySequence uint32 `json:"recovery_sequence"`
// RecoveryLimit is the maximum number of recovery attempts allowed as of
// the time this join state was issued. This field is informational, and is
// expected to be modified server-side to allow additional joins once the
// limit is reached.
RecoveryLimit uint32 `json:"recovery_limit"`
// RecoveryMode is the currently configured recovery mode set in
// `spec.bound_keypair.recovery.mode`. This field is informational; clients
// may opt to use this and the recovery limit to, e.g., generate a warning
// if recovery limits are enforced and the remaining attempts are below some
// threshold.
RecoveryMode string `json:"recovery_mode"`
}
// JoinStateParams contains parameters for issuing and verifying join state
// JWTs.
type JoinStateParams struct {
Clock clockwork.Clock
ClusterName string
Token provision.Token
// HostID is the ID if this JoinState is for a joining agent rather than
// bot, used as the JWT subject. This field is only required for newly
// joining agents; agents performing a hard rejoin (rare but possible) will
// have .status.bound_host_id set in their token resource.
HostID string
}
func (p *JoinStateParams) GetSubject() (string, error) {
switch {
case p.Token.GetBotName() != "":
return p.Token.GetBotName(), nil
case p.HostID != "":
return p.HostID, nil
case p.Token.GetBoundKeypairStatus().BoundHostID != "":
return p.Token.GetBoundKeypairStatus().BoundHostID, nil
default:
return "", trace.BadParameter("invalid join state parameters, one of [.Token.Spec.BotName, .HostID] is required")
}
}
// IssueJoinState generates a join state document from the provided token and
// returns a compact serialized, signed JWT. The token must be up-to-date at the
// time of issuance, i.e. the recovery count must have been incremented already.
func IssueJoinState(signer crypto.Signer, params *JoinStateParams) (string, error) {
spec := params.Token.GetBoundKeypair()
if spec == nil {
return "", trace.BadParameter("spec.bound_keypair: required field is missing")
}
status := params.Token.GetBoundKeypairStatus()
if status == nil {
return "", trace.BadParameter("status.bound_keypair: required field is missing")
}
subject, err := params.GetSubject()
if err != nil {
return "", trace.Wrap(err)
}
state := &JoinState{
Claims: &jwt.Claims{
// We'll reuse the challengeNotBeforeOffset here; the value is sane
// enough.
NotBefore: jwt.NewNumericDate(params.Clock.Now().Add(challengeNotBeforeOffset)),
IssuedAt: jwt.NewNumericDate(params.Clock.Now()),
Issuer: params.ClusterName,
Audience: jwt.Audience{params.ClusterName},
Subject: subject,
// Note: These documents aren't meant to expire, so no expiration is
// included. We may opt to trust (or not) a given document during
// verification based on its `iat` in the future.
},
BotInstanceID: status.BoundBotInstanceID,
HostID: status.BoundHostID,
RecoverySequence: status.RecoveryCount,
RecoveryLimit: spec.Recovery.Limit,
RecoveryMode: spec.Recovery.Mode,
}
// Derive the key ID for inclusion in the header.
kid, err := libjwt.KeyID(signer.Public())
if err != nil {
return "", trace.Wrap(err, "generating key ID")
}
signingKey, err := libjwt.SigningKeyFromPrivateKey(signer)
if err != nil {
return "", trace.Wrap(err)
}
opts := (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid)
joseSigner, err := jose.NewSigner(signingKey, opts)
if err != nil {
return "", trace.Wrap(err, "creating signer")
}
serialized, err := jwt.Signed(joseSigner).Claims(state).CompactSerialize()
if err != nil {
return "", trace.Wrap(err)
}
return serialized, nil
}
func verifyJoinStateInner(key crypto.PublicKey, parsed *jwt.JSONWebToken, params *JoinStateParams) (*JoinState, error) {
status := params.Token.GetBoundKeypairStatus()
if status == nil {
return nil, trace.BadParameter("invalid token status")
}
subject, err := params.GetSubject()
if err != nil {
return nil, trace.Wrap(err)
}
var document JoinState
if err := parsed.Claims(key, &document); err != nil {
return nil, trace.Wrap(err)
}
// Note: We don't verify expiry here, only `iat` and `nbf`. These documents
// are meant to remain "valid" indefinitely and we decide to trust them (or
// not) at verification time.
// There are no time-based recovery restrictions yet, but we may opt to add
// some in the future based on the verified `iat` value of this JWT.
const leeway time.Duration = time.Minute
if err := document.Claims.ValidateWithLeeway(jwt.Expected{
Issuer: params.ClusterName,
Audience: jwt.Audience{params.ClusterName},
Subject: subject,
Time: params.Clock.Now(),
}, leeway); err != nil {
return nil, trace.Wrap(err, "validating join state claims")
}
// Ensure the non-informational claims in the join state match what we
// expect.
var errors []error
if document.RecoverySequence != params.Token.GetBoundKeypairStatus().RecoveryCount {
errors = append(errors, trace.AccessDenied("recovery counter mismatch"))
}
if document.BotInstanceID != params.Token.GetBoundKeypairStatus().BoundBotInstanceID {
errors = append(errors, trace.AccessDenied("bot instance mismatch"))
}
if document.HostID != params.Token.GetBoundKeypairStatus().BoundHostID {
errors = append(errors, trace.AccessDenied("host mismatch"))
}
if len(errors) > 0 {
return nil, trace.NewAggregate(errors...)
}
return &document, nil
}
// VerifyJoinState attempts to verify the given serialized join state JWT
// against the trusted keys in the provided CA and expected join state
// parameters. Note that verification must take place before join state has been
// modified; that is, if a new bot instance is generated or the recovery counter
// is incremented, verification must be done against the original state.
func VerifyJoinState(ca types.CertAuthority, serializedJoinState string, params *JoinStateParams) (*JoinState, error) {
parsed, err := jwt.ParseSigned(serializedJoinState)
if err != nil {
return nil, trace.Wrap(err, "parsing serialized join state")
}
if len(parsed.Headers) == 0 {
return nil, trace.BadParameter("invalid JWT header")
}
expectedKeyID := parsed.Headers[0].KeyID
if expectedKeyID == "" {
return nil, trace.BadParameter("required key ID is missing from JWT header")
}
// Attempt to find the key that signed this JWT.
for _, k := range ca.GetTrustedJWTKeyPairs() {
pubKey, err := keys.ParsePublicKey(k.PublicKey)
if err != nil {
return nil, trace.Wrap(err, "parsing public key")
}
kid, err := libjwt.KeyID(pubKey)
if err != nil {
return nil, trace.Wrap(err, "deriving key ID")
}
if kid == expectedKeyID {
joinState, err := verifyJoinStateInner(pubKey, parsed, params)
if err != nil {
return nil, trace.Wrap(err)
}
return joinState, err
}
}
// No matching keys were found, bail.
return nil, trace.AccessDenied("join state could not be verified")
}