Files
teleport/lib/scopes/joining/token.go
T
Tim Buckley 642aa16ecf Scopes: support tpm joining (#289)
Export-Source-Commit: 395143602db02eeaf5f8e9f2f4ca3e951fcb11ce
2026-08-25 11:35:10 -05:00

1417 lines
48 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 joining
import (
"cmp"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"net/url"
"slices"
"strings"
"time"
"github.com/gravitational/trace"
joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/join/provision"
"github.com/gravitational/teleport/lib/scopes"
)
var rolesSupportingScopes = types.SystemRoles{
types.RoleNode,
types.RoleKube,
types.RoleApp,
types.RoleBot,
}
// TokenUsageMode represents the possible usage modes of a scoped token.
type TokenUsageMode string
const (
// TokenUsageModeSingle denotes a token that can only provision a single resource.
TokenUsageModeSingle TokenUsageMode = "single_use"
// TokenUsageModeUnlimited denotes a token that can provision any number of resources.
TokenUsageModeUnlimited = "unlimited"
// TokenUsageModeBot denotes a token that can provision bots. Bot joining
// perform additional verification steps that can affect token reusability.
TokenUsageModeBot = "bot"
)
// validates the given Kubernetes configuration. Also implemented by
// lib/services/provisioning.go:strongValidateProvisionTokenWithDefaults() for unscoped tokens
func validateKubernetes(kube *joiningv1.Kubernetes) error {
if kube == nil || len(kube.GetAllow()) == 0 {
return trace.BadParameter("at least one allow rule must be set")
}
for i, rule := range kube.GetAllow() {
serviceAccountSet := rule.GetServiceAccount() != ""
serviceAccountNameSet := rule.GetServiceAccountName() != ""
serviceAccountNamespaceSet := rule.GetServiceAccountNamespace() != ""
if !serviceAccountSet && (!serviceAccountNameSet || !serviceAccountNamespaceSet) {
return trace.BadParameter(
"allow[%d]: must specify service_account or (service_account_name and service_account_namespace)",
i,
)
}
if serviceAccountSet {
parts := strings.Split(rule.GetServiceAccount(), ":")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return trace.BadParameter(
"allow[%d].service_account should be in format \"namespace:service_account\", got %q instead",
i,
rule.GetServiceAccount(),
)
}
}
}
switch types.KubernetesJoinType(kube.GetType()) {
case types.KubernetesJoinTypeInCluster:
if kube.GetStaticJwks() != nil {
return trace.BadParameter("static_jwks must not be set when type is %q", kube.GetType())
}
if kube.GetOidc() != nil {
return trace.BadParameter("oidc must not be set when type is %q", kube.GetType())
}
case types.KubernetesJoinTypeStaticJWKS:
if kube.GetStaticJwks().GetJwks() == "" {
return trace.BadParameter("static_jwks must be set when type is %q", kube.GetType())
}
if kube.GetOidc() != nil {
return trace.BadParameter("oidc must not be set when type is %q", kube.GetType())
}
case types.KubernetesJoinTypeOIDC:
if kube.GetOidc().GetIssuer() == "" {
return trace.BadParameter("oidc.issuer must be set when type is %q", kube.GetType())
}
if kube.GetStaticJwks() != nil {
return trace.BadParameter("static_jwks must not be set when type is %q", kube.GetType())
}
parsed, err := url.Parse(kube.GetOidc().GetIssuer())
if err != nil {
return trace.BadParameter("oidc.issuer must be a valid URL")
}
if parsed.Scheme == "http" {
if !kube.GetOidc().GetInsecureAllowHttpIssuer() {
return trace.BadParameter("oidc.issuer must be https:// unless insecure_allow_http_issuer is set")
}
} else if parsed.Scheme != "https" {
return trace.BadParameter("invalid oidc.issuer URL scheme, must be https://")
}
case types.KubernetesJoinTypeUnspecified:
return trace.BadParameter("type must be specified")
default:
return trace.BadParameter(
"unrecognized type %q, must be one of (%s)",
kube.GetType(),
strings.Join([]string{
string(types.KubernetesJoinTypeInCluster),
string(types.KubernetesJoinTypeStaticJWKS),
string(types.KubernetesJoinTypeOIDC),
}, ", "),
)
}
return nil
}
// validates the given AWS EC2 configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenV2.CheckAndSetDefaults() for unscoped tokens
func validateEC2(aws *joiningv1.AWS) error {
ttl := aws.GetIidTtl()
if _, err := types.ParseDuration(ttl); ttl != "" && err != nil {
return trace.BadParameter("invalid IID TTL value %q, must be empty or a valid duration string (e.g. 30m, 12h, 1mo)", ttl)
}
if len(aws.GetAllow()) == 0 {
return trace.BadParameter("aws configuration with at least one allow rule must be defined")
}
for i, rule := range aws.GetAllow() {
if rule.GetAwsArn() != "" {
return trace.BadParameter(`allow[%d]: "aws_arn" parameter not supported`, i)
}
if rule.GetAwsOrganizationId() != "" {
return trace.BadParameter(`allow[%d]: "aws_organization_id" parameter not supported`, i)
}
if rule.GetAwsAccount() == "" && rule.GetAwsRole() == "" {
return trace.BadParameter(`allow[%d]: must set "aws_account" or "aws_role"`, i)
}
}
return nil
}
// validates the given AWS IAM configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenV2.CheckAndSetDefaults() for unscoped tokens
func validateIAM(aws *joiningv1.AWS) error {
if len(aws.GetAllow()) == 0 {
return trace.BadParameter("aws configuration with at least one allow rule must be defined")
}
for i, rule := range aws.GetAllow() {
if rule.GetAwsRole() != "" {
return trace.BadParameter(`allow[%d]: "aws_role" parameter not supported`, i)
}
if len(rule.GetAwsRegions()) != 0 {
return trace.BadParameter(`allow[%d]: "aws_regions" parameter not supported`, i)
}
if rule.GetAwsAccount() == "" && rule.GetAwsArn() == "" && rule.GetAwsOrganizationId() == "" {
return trace.BadParameter(`allow[%d]: must set "aws_account", "aws_arn", or "aws_organization"`, i)
}
}
return nil
}
// validates the given GCP configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenSpecV2GCP.checkAndSetDefaults() for unscoped tokens
func validateGCP(gcp *joiningv1.GCP) error {
if len(gcp.GetAllow()) == 0 {
return trace.BadParameter("gcp configuration with at least one allow rule must be defined")
}
for i, rule := range gcp.GetAllow() {
if len(rule.GetProjectIds()) == 0 {
return trace.BadParameter("allow[%d]: requires at least one project ID", i)
}
for j, projectID := range rule.GetProjectIds() {
if len(projectID) == 0 {
return trace.BadParameter("allow[%d].project_ids[%d]: must not be empty", i, j)
}
}
}
return nil
}
// validates the given Azure configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenSpecV2Azure.checkAndSetDefaults() for unscoped tokens
func validateAzure(azure *joiningv1.Azure) error {
if len(azure.GetAllow()) == 0 {
return trace.BadParameter("azure configuration with at least one allow rule must be defined")
}
for i, rule := range azure.GetAllow() {
if rule.GetSubscription() == "" && rule.GetTenant() == "" {
return trace.BadParameter(`allow[%d]: must set "subscription" or "tenant"`, i)
}
}
return nil
}
// validates the given Azure DevOps configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenSpecV2AzureDevops.checkAndSetDefaults() for unscoped tokens
func validateAzureDevops(azdo *joiningv1.AzureDevops) error {
if len(azdo.GetAllow()) == 0 {
return trace.BadParameter("azure_devops configuration with at least one allow rule must be defined")
}
if azdo.GetOrganizationId() == "" {
return trace.BadParameter(`"organization_id" must be set`)
}
for i, rule := range azdo.GetAllow() {
if rule.GetSub() == "" && rule.GetProjectName() == "" && rule.GetProjectId() == "" {
return trace.BadParameter(
`allow[%d]: must set "sub", "project_name", or "project_id"`,
i,
)
}
}
return nil
}
// validates the given Oracle configuration. Also implemented by
// api/types/provisioning.go:ProvisionTokenSpecV2Oracle.checkAndSetDefaults() for unscoped tokens
func validateOracle(oracle *joiningv1.Oracle) error {
if len(oracle.GetAllow()) == 0 {
return trace.BadParameter("oracle configuration with at least one allow rule must be defined")
}
for i, rule := range oracle.GetAllow() {
if rule.GetTenancy() == "" {
return trace.BadParameter(`allow[%d]: "tenancy" must be set`, i)
}
if len(rule.GetInstances()) > 100 {
return trace.BadParameter("allow[%d]: maximum 100 instances may be set (found %d)", i, len(rule.GetInstances()))
}
}
return nil
}
// validates the given Generic OIDC configuration. Also implemented by
// api/types/provisioning.go for unscoped generic_oidc tokens.
func validateGenericOIDC(spec *joiningv1.GenericOIDC) error {
if spec == nil {
return trace.BadParameter("generic_oidc configuration must be defined for a scoped token when using the generic_oidc join method")
}
if spec.GetIssuer() == "" {
return trace.BadParameter("generic_oidc: issuer is required")
}
if spec.GetAudience() == "" {
return trace.BadParameter("generic_oidc: audience is required")
}
hasAnyAllowAny := len(spec.GetAllowAny()) > 0
hasAnyMustMatchFields := false
if spec.GetMustMatchFields() != nil {
hasAnyMustMatchFields = len(spec.GetMustMatchFields().GetFields()) > 0
}
// At least one must_match_fields or allow_any rule is required; this check
// is a simpler variant of the one in genericoidc's
// `validateFieldRulesContainsAnyRule` and won't catch useless nesting
// checks; we'll catch those at runtime to avoid an unnecessary api/ import.
if !hasAnyAllowAny && !hasAnyMustMatchFields {
return trace.BadParameter("generic_oidc: at least one rule must exist " +
"under either `must_match_fields` or `allow_any`")
}
for i, rule := range spec.GetAllowAny() {
if rule.GetExpression() == "" && len(rule.GetConditions()) == 0 {
return trace.BadParameter("generic_oidc: allow_any[%d]: either `expression` or `conditions` must be set", i)
}
if rule.GetExpression() != "" && len(rule.GetConditions()) > 0 {
return trace.BadParameter("generic_oidc: allow_any[%d]: only one of `expression` or `conditions` may be set", i)
}
for j, cond := range rule.GetConditions() {
if cond.GetAttribute() == "" {
return trace.BadParameter(
"generic_oidc: allow_any[%d].conditions[%d]: an attribute "+
"is required", i, j)
}
conds := 0
if cond.GetEq() != nil {
conds++
}
if cond.GetNotEq() != nil {
conds++
}
if cond.GetIn() != nil {
conds++
}
if cond.GetNotIn() != nil {
conds++
}
if conds == 0 || conds > 1 {
return trace.BadParameter(
"generic_oidc: allow_any[%d].conditions[%d]: exactly one "+
"operator is required", i, j)
}
}
}
parsed, err := url.Parse(spec.GetIssuer())
if err != nil {
return trace.BadParameter("generic_oidc: issuer must be a valid URL")
}
if parsed.Scheme == "http" {
if !spec.GetInsecureAllowHttpIssuer() {
return trace.BadParameter("generic_oidc: issuer must be https:// unless insecure_allow_http_issuer is set")
}
} else if parsed.Scheme != "https" {
return trace.BadParameter("generic_oidc: issuer invalid URL scheme, must be https://")
}
return nil
}
// validateGithub validates the GitHub-specific scoped token configuration.
// It checks that the token usage mode is compatible, that enterprise_server_host
// does not contain a scheme or path, that enterprise_server_host and enterprise_slug
// are mutually exclusive, and that at least one allow rule with a non-empty
// field is set.
func validateGithub(spec *joiningv1.Github, tokenUsageMode TokenUsageMode) error {
if spec == nil {
return trace.BadParameter("github configuration must be defined for a scoped token when using the github join method")
}
if tokenUsageMode == TokenUsageModeSingle {
return trace.BadParameter("usage mode %q is not supported for github join method", TokenUsageModeSingle)
}
if strings.Contains(spec.GetEnterpriseServerHost(), "/") {
return trace.BadParameter("'github.enterprise_server_host' should not contain the scheme or path")
}
if spec.GetEnterpriseServerHost() != "" && spec.GetEnterpriseSlug() != "" {
return trace.BadParameter("'github.enterprise_server_host' and 'github.enterprise_slug' cannot both be set")
}
if len(spec.GetAllow()) == 0 {
return trace.BadParameter("the github join method requires at least one token allow rule")
}
for _, rule := range spec.GetAllow() {
repoSet := rule.GetRepository() != ""
ownerSet := rule.GetRepositoryOwner() != ""
subSet := rule.GetSub() != ""
enterpriseSet := rule.GetEnterprise() != ""
enterpriseIDSet := rule.GetEnterpriseId() != ""
if !subSet && !ownerSet && !repoSet && !enterpriseSet && !enterpriseIDSet {
return trace.BadParameter(`allow rule for github must include at least one of "repository", "repository_owner", "sub", "enterprise" or "enterprise_id"`)
}
}
return nil
}
// validateGitLab validates the GitLab-specific scoped token configuration.
// The token usage mode must not be "single".
// The domain must not contain a scheme or path.
// There must be at least one allow rule.
// Each allow rule must have at least one of ['sub', 'project_path', 'namespace_path', 'ci_config_ref_uri'].
func validateGitLab(spec *joiningv1.GitLab, tokenUsageMode TokenUsageMode) error {
if spec == nil {
return trace.BadParameter("gitlab configuration must be defined for a scoped token when using the gitlab join method")
}
if tokenUsageMode == TokenUsageModeSingle {
return trace.BadParameter("usage mode %q is not supported for gitlab join method", TokenUsageModeSingle)
}
// empty domain field allowed
if spec.GetDomain() != "" && strings.Contains(spec.GetDomain(), "/") {
return trace.BadParameter("'spec.gitlab.domain' should not contain a scheme or path")
}
if len(spec.GetAllow()) == 0 {
return trace.BadParameter("the gitlab join method requires defined gitlab allow rules")
}
for _, allowRule := range spec.GetAllow() {
if allowRule.GetSub() == "" && allowRule.GetNamespacePath() == "" && allowRule.GetProjectPath() == "" && allowRule.GetCiConfigRefUri() == "" {
return trace.BadParameter("the gitlab join method requires allow rules with at least one of ['sub', 'project_path', 'namespace_path', 'ci_config_ref_uri'] to ensure security.")
}
}
return nil
}
// validateTPM validates the TPM-specific scoped token configuration. Note that
// checks from ProvisionTokenSpecV2TPM.validate() are replicated here.
func validateTPM(spec *joiningv1.TPM) error {
if spec == nil {
return trace.BadParameter("tpm: the .spec.tpm field is required for this join method")
}
for i, caData := range spec.GetEkcertAllowedCas() {
p, _ := pem.Decode([]byte(caData))
if p == nil {
return trace.BadParameter(
"ekcert_allowed_cas[%d]: no pem block found",
i,
)
}
if p.Type != "CERTIFICATE" {
return trace.BadParameter(
"ekcert_allowed_cas[%d]: pem block is not 'CERTIFICATE' type",
i,
)
}
if _, err := x509.ParseCertificate(p.Bytes); err != nil {
return trace.Wrap(
err,
"ekcert_allowed_cas[%d]: parsing certificate",
i,
)
}
}
if len(spec.GetAllow()) == 0 {
return trace.BadParameter(
"allow: at least one rule must be set",
)
}
hasCAs := len(spec.GetEkcertAllowedCas()) > 0
for i, allowRule := range spec.GetAllow() {
if len(allowRule.GetEkPublicHash()) == 0 && len(allowRule.GetEkCertificateSerial()) == 0 {
return trace.BadParameter(
"allow[%d]: at least one of ['ek_public_hash', 'ek_certificate_serial'] must be set",
i,
)
}
// This is ported from services/local/provisioning.go's
// validateTPMToken() which was deliberately separate from the overall
// CheckAndSetDefaults() -> validate() path so as to not affect existing
// tokens. There are no existing scoped TPM tokens, so we can safely
// inline it here.
//
// This check doesn't apply if CAs are present: per the source impl,
// serials are not trustworthy when certificates are verified against a
// configured CA, so they're optional if CAs are also set.
hasSerialWithoutHash := allowRule.GetEkCertificateSerial() != "" && allowRule.GetEkPublicHash() == ""
if !hasCAs && hasSerialWithoutHash {
return trace.BadParameter(
"allow[%d]: ek_certificate_serial requires ek_public_hash or "+
"ekcert_allowed_cas to be set so that the EK certificate "+
"can be verified", i)
}
}
return nil
}
// validates per join method token configurations
func validateJoinMethod(token *joiningv1.ScopedToken) error {
switch types.JoinMethod(token.GetSpec().GetJoinMethod()) {
case types.JoinMethodToken:
if token.GetStatus().GetSecret() == "" {
return trace.BadParameter("secret value must be defined for a scoped token when using the token join method")
}
case types.JoinMethodEC2:
return trace.Wrap(validateEC2(token.GetSpec().GetAws()), "ec2 join method")
case types.JoinMethodIAM:
return trace.Wrap(validateIAM(token.GetSpec().GetAws()), "iam join method")
case types.JoinMethodGCP:
return trace.Wrap(validateGCP(token.GetSpec().GetGcp()), "gcp join method")
case types.JoinMethodAzure:
return trace.Wrap(validateAzure(token.GetSpec().GetAzure()), "azure join method")
case types.JoinMethodAzureDevops:
return trace.Wrap(validateAzureDevops(token.GetSpec().GetAzureDevops()), "azure_devops join method")
case types.JoinMethodOracle:
return trace.Wrap(validateOracle(token.GetSpec().GetOracle()), "oracle join method")
case types.JoinMethodKubernetes:
return trace.Wrap(validateKubernetes(token.GetSpec().GetKubernetes()), "kubernetes join method")
case types.JoinMethodBoundKeypair:
// Bound keypair tokens are always valid
case types.JoinMethodGenericOIDC:
return trace.Wrap(validateGenericOIDC(token.GetSpec().GetGenericOidc()), "generic_oidc join method")
case types.JoinMethodGitHub:
if err := validateGithub(token.GetSpec().GetGithub(), TokenUsageMode(token.GetSpec().GetUsageMode())); err != nil {
return trace.Wrap(err, "github join method")
}
case types.JoinMethodGitLab:
return trace.Wrap(validateGitLab(token.GetSpec().GetGitlab(), TokenUsageMode(token.GetSpec().GetUsageMode())), "gitlab join method")
case types.JoinMethodTPM:
return trace.Wrap(validateTPM(token.GetSpec().GetTpm()), "tpm join method")
default:
return trace.BadParameter("join method %q does not support scoping", token.GetSpec().GetJoinMethod())
}
return nil
}
// validateBot is used to validate plausibly-bot tokens (if `isBotToken()` has
// returned true).
func strongValidateBotToken(token *joiningv1.ScopedToken, roles types.SystemRoles) error {
spec := token.GetSpec()
if spec.GetBot() == "" {
return trace.BadParameter("expected non-empty bot for a scoped bot token")
}
bot, err := scopes.ParseQualifiedName(spec.GetBot())
if err != nil {
return trace.Wrap(err, "validating scoped token bot")
}
if err := bot.StrongValidate(); err != nil {
return trace.Wrap(err, "validating scoped token bot")
}
if spec.GetUsageMode() != TokenUsageModeBot {
return trace.BadParameter("usage_mode must be '%s' for a scoped bot token", TokenUsageModeBot)
}
if len(roles) != 1 || !roles.Include(types.RoleBot) {
return trace.BadParameter("roles must only be '[Bot]' for a scoped bot token")
}
if !scopes.ScopeOfOrigin(token.GetScope()).IsAssignableToScopeOfEffect(bot.Scope) {
return trace.BadParameter("scoped token bot scope must be a descendant of or equivalent to its resource scope")
}
if spec.GetAssignedScope() != "" {
return trace.BadParameter("scoped tokens for bots cannot have an assigned_scope")
}
if spec.GetJoinMethod() == string(types.JoinMethodToken) {
return trace.BadParameter("scoped bot tokens do not support the `token` join method, `bound_keypair` should be used instead")
}
return nil
}
// validateNonBot performs checks for scoped tokens that explicitly should not
// exist for non-bot tokens.
func validateNonBotToken(token *joiningv1.ScopedToken) error {
spec := token.GetSpec()
if spec.GetBot() != "" {
return trace.BadParameter("bot cannot be set for a non-bot token")
}
if spec.GetUsageMode() == TokenUsageModeBot {
return trace.BadParameter("usage_mode cannot be 'bot' for a non-bot token")
}
if err := scopes.StrongValidate(spec.GetAssignedScope()); err != nil {
return trace.Wrap(err, "validating scoped token assigned scope")
}
if !scopes.ScopeOfOrigin(token.GetScope()).IsAssignableToScopeOfEffect(spec.GetAssignedScope()) {
return trace.BadParameter("scoped token assigned scope must be descendant of or equivalent to the token's resource scope")
}
return nil
}
// validateBotRef weak-validates the bot reference of a scoped token spec and
// returns the referenced bot's name and scope, parsed from the
// scope-qualified name in the bot field. Bot tokens must carry a well-formed
// scope-qualified name. For non-bot tokens the bot field is ignored and empty
// values are returned: a stray bot field on a non-bot token is rejected by
// strong validation at write time, but tolerated here to match weak
// validation's posture toward existing resources.
func validateBotRef(spec *joiningv1.ScopedTokenSpec, isBotToken bool) (name, scope string, err error) {
if !isBotToken {
return "", "", nil
}
if spec.GetBot() == "" {
return "", "", trace.BadParameter("expected non-empty bot for a scoped bot token")
}
bot, err := scopes.ParseQualifiedName(spec.GetBot())
if err != nil {
return "", "", trace.Wrap(err, "validating scoped token bot")
}
if err := bot.WeakValidate(); err != nil {
return "", "", trace.Wrap(err, "validating scoped token bot")
}
return bot.Name, bot.Scope, nil
}
// StrongValidateToken checks if the scoped token is well-formed according to
// all scoped token rules. This function *must* be used to validate any scoped
// token being created from scratch. When validating existing scoped token
// resources, this function should be avoided in favor of the
// [WeakValidateToken] function.
func StrongValidateToken(token *joiningv1.ScopedToken) error {
if expected, actual := types.KindScopedToken, token.GetKind(); expected != actual {
return trace.BadParameter("expected kind %v, got %q", expected, actual)
}
if expected, actual := types.V1, token.GetVersion(); expected != actual {
return trace.BadParameter("expected version %v, got %q", expected, actual)
}
if expected, actual := "", token.GetSubKind(); expected != actual {
return trace.BadParameter("expected sub_kind %v, got %q", expected, actual)
}
if err := scopes.StrongValidateResourceName(token.GetMetadata().GetName()); err != nil {
return trace.Wrap(err, "validating scoped token name")
}
if token.GetScope() == "" {
return trace.BadParameter("scoped token must have a scope assigned")
}
spec := token.GetSpec()
if spec == nil {
return trace.BadParameter("spec must not be nil")
}
if err := scopes.StrongValidate(token.GetScope()); err != nil {
return trace.Wrap(err, "validating scoped token resource scope")
}
if err := validateJoinMethod(token); err != nil {
return trace.Wrap(err)
}
switch TokenUsageMode(spec.GetUsageMode()) {
case TokenUsageModeSingle, TokenUsageModeUnlimited, TokenUsageModeBot:
default:
return trace.BadParameter("scoped token mode is not supported")
}
if len(spec.GetRoles()) == 0 {
return trace.BadParameter("scoped token must have at least one role")
}
roles, err := types.NewTeleportRoles(spec.GetRoles())
if err != nil {
return trace.Wrap(err, "validating scoped token roles")
}
if err := validateImmutableLabels(spec); err != nil {
return trace.Wrap(err)
}
for _, role := range roles {
if !rolesSupportingScopes.Include(role) {
return trace.BadParameter("role %q does not support scoping", role)
}
}
if roles.Include(types.RoleBot) {
if err := strongValidateBotToken(token, roles); err != nil {
return trace.Wrap(err)
}
} else {
if err := validateNonBotToken(token); err != nil {
return trace.Wrap(err)
}
}
return nil
}
// WeakValidateToken performs a weak form of validation on a scoped token. This
// function is intended to catch bugs/incompatibilites that might have resulted
// in a scoped token too malformed for us to safely reason about (e.g. due to
// significant version drift). Use this function to validate scoped tokens
// propagated from the control plane. Prefer using [StrongValidateToken] when
// building a new scoped token from scratch.
func WeakValidateToken(token *joiningv1.ScopedToken) error {
if token == nil {
return trace.BadParameter("missing scoped token")
}
if err := scopes.WeakValidate(token.GetScope()); err != nil {
return trace.Wrap(err, "validating scoped token resource scope")
}
spec := token.GetSpec()
// Determine if this is a bot token without potentially trying to validate
// other unknown roles.
isBotToken := slices.Contains(spec.GetRoles(), string(types.RoleBot))
if _, _, err := validateBotRef(spec, isBotToken); err != nil {
return trace.Wrap(err)
}
if !isBotToken {
if err := scopes.WeakValidate(token.GetSpec().GetAssignedScope()); err != nil {
return trace.Wrap(err, "validating scoped token assigned scope")
}
}
if len(token.GetSpec().GetRoles()) == 0 {
return trace.BadParameter("scoped token must have at least one role")
}
if err := validateJoinMethod(token); err != nil {
return trace.Wrap(err)
}
return nil
}
var ErrTokenExpired = &trace.LimitExceededError{Message: "scoped token is expired"}
var ErrTokenExhausted = &trace.LimitExceededError{Message: "scoped token usage exhausted"}
// ValidateTokenForUse checks if a given scoped token can be used for
// provisioning. Returns a [*trace.LimitExceededError] if the token is expired
func ValidateTokenForUse(token *joiningv1.ScopedToken, features scopes.Features) error {
if err := features.AssertEnabled(); err != nil {
return trace.Wrap(err)
}
if err := StrongValidateToken(token); err != nil {
return trace.Wrap(err)
}
now := time.Now().UTC()
ttl := token.GetMetadata().GetExpires()
if ttl != nil && !ttl.AsTime().IsZero() {
if ttl.AsTime().Before(now) {
return trace.Wrap(ErrTokenExpired)
}
}
reusableUntil := token.GetStatus().GetUsage().GetSingleUse().GetReusableUntil()
if reusableUntil != nil && !reusableUntil.AsTime().IsZero() {
if reusableUntil.AsTime().Before(now) {
return trace.Wrap(ErrTokenExhausted)
}
}
// if agent scope pins are enabled, all system roles are allowed to join
// with a scoped token
if features.AgentPinEnabled {
return nil
}
// if agent scope pins are disabled, then we need to ensure that only node and
// bot roles can be provisioned with a scoped token. Using [slices.ContainsFunc]
// to make it easier to fail closed
roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles())
if err != nil {
return trace.Wrap(err, "normalizing system roles")
}
if !slices.ContainsFunc(roles, func(role types.SystemRole) bool {
return role != types.RoleNode && role != types.RoleBot
}) {
return nil
}
return trace.BadParameter("scoped token cannot be used to join [%s] role(s) without TELEPORT_UNSTABLE_AGENT_SCOPE_PIN=yes", strings.Join(token.GetSpec().GetRoles(), ", "))
}
// ValidateTokenUpdate checks for invalid updates between two tokens.
// If the scope, usage mode, or secret was changed between two token updates,
// a trace.BadParameter error is returned.
func ValidateTokenUpdate(oldToken *joiningv1.ScopedToken, newToken *joiningv1.ScopedToken) error {
if newToken == nil {
return trace.BadParameter("new token is invalid")
}
// no old token to compare to so we assume that the new token is valid and no need for additional checks
if oldToken == nil {
return nil
}
tokenName := newToken.GetMetadata().GetName()
if oldToken.GetScope() != newToken.GetScope() {
return trace.BadParameter("cannot modify scope of existing scoped token %s with scope %s to %s", tokenName, oldToken.GetScope(), newToken.GetScope())
}
if oldToken.GetSpec().GetUsageMode() != newToken.GetSpec().GetUsageMode() {
return trace.BadParameter("cannot modify usage mode of existing scoped token %s from usage mode %s to %s", tokenName, oldToken.GetSpec().GetUsageMode(), newToken.GetSpec().GetUsageMode())
}
// If the new Token does not have a status, this should indicate that the status was not sent, meaning no change to the underlying secret
if newToken.GetStatus() != nil && oldToken.GetStatus().GetSecret() != newToken.GetStatus().GetSecret() {
return trace.BadParameter("cannot modify secret of existing scoped token %s", tokenName)
}
return nil
}
// Token wraps a [joiningv1.ScopedToken] such that it can be used to provision
// resources.
type Token struct {
scoped *joiningv1.ScopedToken
joinMethod types.JoinMethod
roles types.SystemRoles
botName string
botScope string
}
// NewToken returns the wrapped version of the given [joiningv1.ScopedToken].
// It will return an error if the configured join method is not a valid
// [types.JoinMethod], if any of the configured roles are not a valid
// [types.SystemRole], or if the bot field is inconsistent with the configured
// roles. The validated join method, roles, and bot reference are cached on
// the [Token] wrapper itself so they can be read without repeating
// validation.
func NewToken(token *joiningv1.ScopedToken) (*Token, error) {
joinMethod := types.JoinMethod(token.GetSpec().GetJoinMethod())
if err := types.ValidateJoinMethod(joinMethod); err != nil {
return nil, trace.Wrap(err)
}
roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles())
if err != nil {
return nil, trace.Wrap(err)
}
botName, botScope, err := validateBotRef(token.GetSpec(), roles.Include(types.RoleBot))
if err != nil {
return nil, trace.Wrap(err)
}
return &Token{
scoped: token,
joinMethod: joinMethod,
roles: roles,
botName: botName,
botScope: botScope,
}, nil
}
// GetName returns the name of a [joiningv1.ScopedToken].
func (t *Token) GetName() string {
if t == nil {
return ""
}
return t.scoped.GetMetadata().GetName()
}
// GetJoinMethod returns the cached [types.JoinMethod] generated when the
// [joiningv1.ScopedToken] was wrapped.
func (t *Token) GetJoinMethod() types.JoinMethod {
if t == nil {
return types.JoinMethodUnspecified
}
return t.joinMethod
}
// GetRoles returns the cached [types.SystemRoles] generated when the
// [joiningv1.ScopedToken] was wrapped.
func (t *Token) GetRoles() types.SystemRoles {
if t == nil {
return nil
}
return t.roles
}
// GetSafeName returns the name the santiized name of the scoped token. Because
// scoped token names are not secret, this is just an alias for [GetName].
func (t *Token) GetSafeName() string {
return t.GetName()
}
// Expiry returns the [time.Time] representing when the wrapped
// [joiningv1.ScopedToken] will expire.
func (t *Token) Expiry() time.Time {
expiry := t.scoped.GetMetadata().GetExpires()
if expiry == nil {
return time.Time{}
}
return expiry.AsTime()
}
// GetBot returns the cached name and scope of the bot that this token can
// join, validated when the [joiningv1.ScopedToken] was wrapped. An empty name
// indicates that this is not a bot token; otherwise both components are
// non-empty.
func (t *Token) GetBot() (name, scope string) {
if t == nil {
return "", ""
}
return t.botName, t.botScope
}
// GetAssignedScope returns the scope that will be assigned to resources
// provisioned using the wrapped [joiningv1.ScopedToken].
func (t *Token) GetAssignedScope() string {
return t.scoped.GetSpec().GetAssignedScope()
}
// GetScope returns the scope of the wrapped [joiningv1.ScopedToken].
func (t *Token) GetScope() string {
return t.scoped.GetScope()
}
// GetSecret returns the token's secret value.
func (t *Token) GetSecret() (string, bool) {
return t.scoped.GetStatus().GetSecret(), t.GetJoinMethod() == types.JoinMethodToken
}
// GetAllowRules returns the list of allow rules.
func (t *Token) GetAWSAllowRules() []*types.TokenRule {
allow := make([]*types.TokenRule, len(t.scoped.GetSpec().GetAws().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetAws().GetAllow() {
allow[i] = &types.TokenRule{
AWSAccount: rule.GetAwsAccount(),
AWSRegions: rule.GetAwsRegions(),
AWSRole: rule.GetAwsRole(),
AWSARN: rule.GetAwsArn(),
AWSOrganizationID: rule.GetAwsOrganizationId(),
}
}
return allow
}
// GetAWSIIDTTL returns the TTL of EC2 IIDs
func (t *Token) GetAWSIIDTTL() types.Duration {
ttl, err := types.ParseDuration(t.scoped.GetSpec().GetAws().GetIidTtl())
if err != nil {
// if parsing fails for any reason (including an empty value) we fallback to the 5 minute default
return types.Duration(5 * time.Minute)
}
return ttl
}
// GetIntegration returns the Integration field which is used to provide
// credentials that will be used when validating the AWS Organization if required by an IAM Token.
func (t *Token) GetIntegration() string {
return t.scoped.GetSpec().GetAws().GetIntegration()
}
// GetGCPRules returns the GCP-specific configuration for this token.
func (t *Token) GetGCPRules() *types.ProvisionTokenSpecV2GCP {
allow := make([]*types.ProvisionTokenSpecV2GCP_Rule, len(t.scoped.GetSpec().GetGcp().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetGcp().GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2GCP_Rule{
ProjectIDs: rule.GetProjectIds(),
Locations: rule.GetLocations(),
ServiceAccounts: rule.GetServiceAccounts(),
}
}
return &types.ProvisionTokenSpecV2GCP{
Allow: allow,
}
}
// GetAzure returns the Azure-specific configuration for this token.
func (t *Token) GetAzure() *types.ProvisionTokenSpecV2Azure {
allow := make([]*types.ProvisionTokenSpecV2Azure_Rule, len(t.scoped.GetSpec().GetAzure().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetAzure().GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2Azure_Rule{
Tenant: rule.GetTenant(),
Subscription: rule.GetSubscription(),
ResourceGroups: rule.GetResourceGroups(),
}
}
return &types.ProvisionTokenSpecV2Azure{
Allow: allow,
}
}
// GetAzureDevops returns the AzureDevops-specific configuration for this token.
func (t *Token) GetAzureDevops() *types.ProvisionTokenSpecV2AzureDevops {
allow := make([]*types.ProvisionTokenSpecV2AzureDevops_Rule, len(t.scoped.GetSpec().GetAzureDevops().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetAzureDevops().GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2AzureDevops_Rule{
Sub: rule.GetSub(),
ProjectName: rule.GetProjectName(),
PipelineName: rule.GetPipelineName(),
ProjectID: rule.GetProjectId(),
DefinitionID: rule.GetDefinitionId(),
RepositoryURI: rule.GetRepositoryUri(),
RepositoryVersion: rule.GetRepositoryVersion(),
RepositoryRef: rule.GetRepositoryRef(),
}
}
return &types.ProvisionTokenSpecV2AzureDevops{
Allow: allow,
OrganizationID: t.scoped.GetSpec().GetAzureDevops().GetOrganizationId(),
}
}
// GetOracle returns the Oracle-specific configuration for this token.
func (t *Token) GetOracle() *types.ProvisionTokenSpecV2Oracle {
allow := make([]*types.ProvisionTokenSpecV2Oracle_Rule, len(t.scoped.GetSpec().GetOracle().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetOracle().GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2Oracle_Rule{
Tenancy: rule.GetTenancy(),
ParentCompartments: rule.GetParentCompartments(),
Regions: rule.GetRegions(),
Instances: rule.GetInstances(),
}
}
return &types.ProvisionTokenSpecV2Oracle{
Allow: allow,
}
}
// GetKubernetes returns the Kubernetes-specific configuration for this token.
func (t *Token) GetKubernetes() *types.ProvisionTokenSpecV2Kubernetes {
allow := make([]*types.ProvisionTokenSpecV2Kubernetes_Rule, len(t.scoped.GetSpec().GetKubernetes().GetAllow()))
for i, rule := range t.scoped.GetSpec().GetKubernetes().GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2Kubernetes_Rule{
ServiceAccount: rule.GetServiceAccount(),
ServiceAccountName: rule.GetServiceAccountName(),
ServiceAccountNamespace: rule.GetServiceAccountNamespace(),
}
}
var staticJWKS *types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig
if jwks := t.scoped.GetSpec().GetKubernetes().GetStaticJwks().GetJwks(); jwks != "" {
staticJWKS = &types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig{
JWKS: jwks,
}
}
var oidcConfig *types.ProvisionTokenSpecV2Kubernetes_OIDCConfig
if oidc := t.scoped.GetSpec().GetKubernetes().GetOidc(); oidc != nil {
oidcConfig = &types.ProvisionTokenSpecV2Kubernetes_OIDCConfig{
Issuer: oidc.GetIssuer(),
InsecureAllowHTTPIssuer: oidc.GetInsecureAllowHttpIssuer(),
}
}
return &types.ProvisionTokenSpecV2Kubernetes{
Allow: allow,
Type: types.KubernetesJoinType(t.scoped.GetSpec().GetKubernetes().GetType()),
StaticJWKS: staticJWKS,
OIDC: oidcConfig,
}
}
// GetBoundKeypair returns the bound keypair-specific configuration for this
// token.
func (t *Token) GetBoundKeypair() *types.ProvisionTokenSpecV2BoundKeypair {
return BoundKeypairSpecFromScopedToken(t.scoped)
}
// GetBoundKeypairStatus returns the bound keypair-specific status for this
// token.
func (t *Token) GetBoundKeypairStatus() *types.ProvisionTokenStatusV2BoundKeypair {
return BoundKeypairStatusFromScopedToken(t.scoped)
}
// convertGenericOIDCCondition converts a scoped generic_oidc condition to a
// ProvisionTokenV2-style condition (with gogoproto semantics).
func convertGenericOIDCCondition(c *joiningv1.GenericOIDC_Condition) (*types.ProvisionTokenSpecV2GenericOIDC_Condition, error) {
v := &types.ProvisionTokenSpecV2GenericOIDC_Condition{
Attribute: c.GetAttribute(),
}
switch {
case c.GetEq() != nil:
v.Eq = &types.ProvisionTokenSpecV2GenericOIDC_ConditionEq{
Value: c.GetEq().GetValue(),
}
case c.GetNotEq() != nil:
v.NotEq = &types.ProvisionTokenSpecV2GenericOIDC_ConditionNotEq{
Value: c.GetNotEq().GetValue(),
}
case c.GetIn() != nil:
v.In = &types.ProvisionTokenSpecV2GenericOIDC_ConditionIn{
Values: c.GetIn().GetValues(),
}
case c.GetNotIn() != nil:
v.NotIn = &types.ProvisionTokenSpecV2GenericOIDC_ConditionNotIn{
Values: c.GetNotIn().GetValues(),
}
default:
return nil, trace.BadParameter("an operator is required but found none")
}
return v, nil
}
// GetGenericOIDC returns the generic_oidc-specific configuration for this token.
func (t *Token) GetGenericOIDC() (*types.ProvisionTokenSpecV2GenericOIDC, error) {
spec := t.scoped.GetSpec().GetGenericOidc()
var globalMatchers *types.Struct
if gm := spec.GetMustMatchFields(); gm != nil {
gogo, err := convertStructPB(spec.GetMustMatchFields())
if err != nil {
return nil, trace.Wrap(err)
}
globalMatchers = gogo
}
allow := make([]*types.ProvisionTokenSpecV2GenericOIDC_Rule, len(spec.GetAllowAny()))
for i, rule := range spec.GetAllowAny() {
conditions := make([]*types.ProvisionTokenSpecV2GenericOIDC_Condition, len(rule.GetConditions()))
for j, condition := range rule.GetConditions() {
converted, err := convertGenericOIDCCondition(condition)
if err != nil {
return nil, trace.Wrap(err)
}
conditions[j] = converted
}
allow[i] = &types.ProvisionTokenSpecV2GenericOIDC_Rule{
Expression: rule.GetExpression(),
Conditions: conditions,
}
}
return &types.ProvisionTokenSpecV2GenericOIDC{
Issuer: spec.GetIssuer(),
InsecureAllowHTTPIssuer: spec.GetInsecureAllowHttpIssuer(),
Audience: spec.GetAudience(),
StaticJWKS: spec.GetStaticJwks(),
TLSCA: spec.GetTlsCa(),
MustMatchFields: globalMatchers,
AllowAny: allow,
}, nil
}
func (t *Token) GetGithub() *types.ProvisionTokenSpecV2GitHub {
spec := t.scoped.GetSpec().GetGithub()
allow := make([]*types.ProvisionTokenSpecV2GitHub_Rule, len(spec.GetAllow()))
for i, rule := range spec.GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2GitHub_Rule{
Sub: rule.GetSub(),
Repository: rule.GetRepository(),
RepositoryOwner: rule.GetRepositoryOwner(),
Workflow: rule.GetWorkflow(),
Environment: rule.GetEnvironment(),
Actor: rule.GetActor(),
Ref: rule.GetRef(),
RefType: rule.GetRefType(),
Enterprise: rule.GetEnterprise(),
EnterpriseID: rule.GetEnterpriseId(),
}
}
return &types.ProvisionTokenSpecV2GitHub{
EnterpriseServerHost: spec.GetEnterpriseServerHost(),
EnterpriseSlug: spec.GetEnterpriseSlug(),
StaticJWKS: spec.GetStaticJwks(),
Allow: allow,
}
}
func (t *Token) GetGitLab() *types.ProvisionTokenSpecV2GitLab {
spec := t.scoped.GetSpec().GetGitlab()
allow := make([]*types.ProvisionTokenSpecV2GitLab_Rule, len(spec.GetAllow()))
for i, rule := range spec.GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2GitLab_Rule{
Sub: rule.GetSub(),
Ref: rule.GetRef(),
RefType: rule.GetRefType(),
NamespacePath: rule.GetNamespacePath(),
ProjectPath: rule.GetProjectPath(),
PipelineSource: rule.GetPipelineSource(),
Environment: rule.GetEnvironment(),
UserLogin: rule.GetUserLogin(),
UserID: rule.GetUserId(),
UserEmail: rule.GetUserEmail(),
CIConfigSHA: rule.GetCiConfigSha(),
CIConfigRefURI: rule.GetCiConfigRefUri(),
DeploymentTier: rule.GetDeploymentTier(),
ProjectVisibility: rule.GetProjectVisibility(),
}
if rule.HasRefProtected() {
allow[i].RefProtected = types.NewBoolOption(rule.GetRefProtected())
}
if rule.HasEnvironmentProtected() {
allow[i].EnvironmentProtected = types.NewBoolOption(rule.GetEnvironmentProtected())
}
}
return &types.ProvisionTokenSpecV2GitLab{
Domain: spec.GetDomain(),
StaticJWKS: spec.GetStaticJwks(),
Allow: allow,
}
}
// GetTPM returns the TPM configuration for this token. Returns an empty but
// not nil value if TPM was not configured.
func (t *Token) GetTPM() *types.ProvisionTokenSpecV2TPM {
spec := t.scoped.GetSpec().GetTpm()
allow := make([]*types.ProvisionTokenSpecV2TPM_Rule, len(spec.GetAllow()))
for i, rule := range spec.GetAllow() {
allow[i] = &types.ProvisionTokenSpecV2TPM_Rule{
Description: rule.GetDescription(),
EKPublicHash: rule.GetEkPublicHash(),
EKCertificateSerial: rule.GetEkCertificateSerial(),
}
}
return &types.ProvisionTokenSpecV2TPM{
Allow: allow,
EKCertAllowedCAs: spec.GetEkcertAllowedCas(),
}
}
// GetScoped returns the inner scoped token wrapped by this [provision.Token].
func (t *Token) GetScoped() *joiningv1.ScopedToken {
return t.scoped
}
// GetScopedToken attempts to return the underlying [*joiningv1.ScopedToken] backing a
// [provision.Token]. Returns a boolean indicating whether the token is scoped or not.
func GetScopedToken(token provision.Token) (*joiningv1.ScopedToken, bool) {
wrapper, ok := token.(*Token)
if !ok {
return nil, false
}
return wrapper.scoped, true
}
// GetImmutableLabels returns labels that must be applied to resources
// provisioned with this token.
func (t *Token) GetImmutableLabels() *joiningv1.ImmutableLabels {
return t.scoped.GetSpec().GetImmutableLabels()
}
// BoundKeypairSpecFromScopedToken converts the bound keypair spec from a
// [*joiningv1.ScopedToken] to the equivalent
// [*types.ProvisionTokenSpecV2BoundKeypair].
func BoundKeypairSpecFromScopedToken(token *joiningv1.ScopedToken) *types.ProvisionTokenSpecV2BoundKeypair {
spec := token.GetSpec().GetBoundKeypair()
var mustRegisterBefore, rotateAfter *time.Time
if m := spec.GetOnboarding().GetMustRegisterBefore(); m != nil {
t := m.AsTime()
mustRegisterBefore = &t
}
if v := spec.GetRotateAfter(); v != nil {
t := v.AsTime()
rotateAfter = &t
}
return &types.ProvisionTokenSpecV2BoundKeypair{
Onboarding: &types.ProvisionTokenSpecV2BoundKeypair_OnboardingSpec{
RegistrationSecret: spec.GetOnboarding().GetRegistrationSecret(),
InitialPublicKey: spec.GetOnboarding().GetInitialPublicKey(),
MustRegisterBefore: mustRegisterBefore,
},
Recovery: &types.ProvisionTokenSpecV2BoundKeypair_RecoverySpec{
Limit: spec.GetRecovery().GetLimit(),
Mode: spec.GetRecovery().GetMode(),
},
RotateAfter: rotateAfter,
}
}
// BoundKeypairStatusFromScopedToken converts the bound keypair status from a
// [*joiningv1.ScopedToken] to the equivalent
// [*types.ProvisionTokenStatusV2BoundKeypair].
func BoundKeypairStatusFromScopedToken(token *joiningv1.ScopedToken) *types.ProvisionTokenStatusV2BoundKeypair {
status := token.GetStatus().GetUsage().GetBoundKeypair()
var lastRecoveredAt, lastRotatedAt *time.Time
if val := status.GetLastRecoveredAt(); val != nil {
v := val.AsTime()
lastRecoveredAt = &v
}
if val := status.GetLastRotatedAt(); val != nil {
v := val.AsTime()
lastRotatedAt = &v
}
return &types.ProvisionTokenStatusV2BoundKeypair{
RegistrationSecret: status.GetRegistrationSecret(),
BoundPublicKey: status.GetBoundPublicKey(),
BoundBotInstanceID: status.GetBoundBotInstanceId(),
BoundHostID: status.GetBoundHostId(),
RecoveryCount: status.GetRecoveryCount(),
LastRecoveredAt: lastRecoveredAt,
LastRotatedAt: lastRotatedAt,
}
}
func validateImmutableLabels(spec *joiningv1.ScopedTokenSpec) error {
if spec == nil {
return nil
}
sshLabels := spec.GetImmutableLabels().GetSsh()
if len(sshLabels) > 0 {
if !slices.Contains(spec.GetRoles(), string(types.RoleNode)) {
return trace.BadParameter("immutable ssh labels are only supported for tokens that allow the node role")
}
}
for k := range sshLabels {
if !types.IsValidLabelKey(k) {
return trace.BadParameter("invalid immutable label key %q", k)
}
}
return nil
}
// HashImmutableLabels returns a deterministic hash of the given [*joiningv1.ImmutableLabels].
func HashImmutableLabels(labels *joiningv1.ImmutableLabels) string {
if labels == nil {
return ""
}
hash := sha256.New()
var bytesWrittenToHash int
writeHash := func(p []byte) {
n, _ := hash.Write(p)
bytesWrittenToHash += n
}
if sshLabels := labels.GetSsh(); len(sshLabels) > 0 {
sorted := make([]struct{ key, value string }, 0, len(sshLabels))
for k, v := range sshLabels {
sorted = append(sorted, struct{ key, value string }{k, v})
}
slices.SortFunc(sorted, func(a, b struct{ key, value string }) int {
return cmp.Compare(a.key, b.key)
})
// first we write the service type so that the following labels do not collide with identical labels
// from other services e.g. app labels or database labels
writeHash([]byte("ssh"))
// Each map entry is added to the hash as 4 components:
// 1. The length of the key
// 2. The value of the key
// 3. The length of the value
// 4. The value itself
// This combination prevents collisions between:
// - single labels (e.g. aaa=bbb and aaab=bb)
// - splitting labels (e.g. aaa=bbbcccddd and aaa=bbb,ccc=ddd)
// ...because in both cases the lengths of the keys/values must change to create different labels from
// the same set of characters.
for _, v := range sorted {
buf := [8]byte{}
binary.BigEndian.PutUint64(buf[:], uint64(len(v.key)))
writeHash(buf[:])
writeHash([]byte(v.key))
binary.BigEndian.PutUint64(buf[:], uint64(len(v.value)))
writeHash(buf[:])
writeHash([]byte(v.value))
}
}
if bytesWrittenToHash == 0 {
return ""
}
return hex.EncodeToString(hash.Sum(nil))
}
// VerifyImmutableLabelsHash returns whether or not the given [*joiningv1.ImmutableLabels]
// matches the given hash.
func VerifyImmutableLabelsHash(labels *joiningv1.ImmutableLabels, hash string) bool {
newHash := HashImmutableLabels(labels)
return newHash == hash
}
const tokenNameAndSecretSeparator = ":"
// EncodeScopedToken combines a token name and secret into a single encoded value. The encoded
// tokens are used when providing tokens externally to end users to ease UX.
func EncodeScopedToken(name, secret string) string {
return name + tokenNameAndSecretSeparator + base64.RawURLEncoding.EncodeToString([]byte(secret))
}
// DecodeScopedToken produces a token name and secret from an encoded value. If the token
// is not encoded the return value is token, "", false.
func DecodeScopedToken(token string) (name string, secret string, ok bool) {
name, base64Secret, ok := strings.Cut(token, tokenNameAndSecretSeparator)
if !ok {
return token, "", false
}
s, err := base64.RawURLEncoding.DecodeString(base64Secret)
if err != nil {
return token, "", false
}
return name, string(s), true
}