Files
Noah Stride f7aef78a96 MWI: Bot Scope Namespacing (#68253)
* MWI: Bot Scope Namespacing (#68253)

* Properly handle scope namespaced bots in tctl

Squash of:
- Properly handle scope namespaced bots in tctl
- tdy

* tidy up todo/lint failure

* Add audit log events

* various tidying

* adjust for consistency

* remove botRef

* Simplify by removing checkBotRefMatches

* Avoid testify in authtest.go

* address review feedback

* defensive checks against name collisions
2026-08-06 09:55:16 +00:00

266 lines
9.3 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"
"github.com/gravitational/teleport/lib/scopes"
)
// 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) {
botName, botScope := p.Token.GetBot()
switch {
case botName != "":
// Bots are namespaced by scope, so only the scope-qualified name
// identifies one uniquely. Unscoped bots keep the bare name.
return scopes.QualifiedName{Scope: botScope, Name: botName}.String(), 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")
}