mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
* 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
262 lines
9.0 KiB
Go
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")
|
|
}
|