diff --git a/lib/auth/auth.go b/lib/auth/auth.go index 6554fdf8b5188..cc74412bd5210 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -3212,37 +3212,39 @@ func generateCert(ctx context.Context, a *Server, req certRequest, caType types. return nil, trace.Wrap(err) } - params := services.UserCertParams{ - CASigner: sshSigner, - PublicUserKey: req.sshPublicKey, - Username: req.user.GetName(), - Impersonator: req.impersonator, - AllowedLogins: allowedLogins, - TTL: sessionTTL, - Roles: req.checker.RoleNames(), - CertificateFormat: certificateFormat, - PermitPortForwarding: req.checker.CanPortForward(), - PermitAgentForwarding: req.checker.CanForwardAgents(), - PermitX11Forwarding: req.checker.PermitX11Forwarding(), - RouteToCluster: req.routeToCluster, - Traits: req.traits, - ActiveRequests: req.activeRequests, - MFAVerified: req.mfaVerified, - PreviousIdentityExpires: req.previousIdentityExpires, - LoginIP: req.loginIP, - PinnedIP: pinnedIP, - DisallowReissue: req.disallowReissue, - Renewable: req.renewable, - Generation: req.generation, - BotName: req.botName, - BotInstanceID: req.botInstanceID, - CertificateExtensions: req.checker.CertificateExtensions(), - AllowedResourceIDs: requestedResourcesStr, - ConnectionDiagnosticID: req.connectionDiagnosticID, - PrivateKeyPolicy: attestedKeyPolicy, - DeviceID: req.deviceExtensions.DeviceID, - DeviceAssetTag: req.deviceExtensions.AssetTag, - DeviceCredentialID: req.deviceExtensions.CredentialID, + params := sshca.UserCertificateRequest{ + CASigner: sshSigner, + PublicUserKey: req.sshPublicKey, + TTL: sessionTTL, + CertificateFormat: certificateFormat, + Identity: sshca.Identity{ + Username: req.user.GetName(), + Impersonator: req.impersonator, + AllowedLogins: allowedLogins, + Roles: req.checker.RoleNames(), + PermitPortForwarding: req.checker.CanPortForward(), + PermitAgentForwarding: req.checker.CanForwardAgents(), + PermitX11Forwarding: req.checker.PermitX11Forwarding(), + RouteToCluster: req.routeToCluster, + Traits: req.traits, + ActiveRequests: req.activeRequests, + MFAVerified: req.mfaVerified, + PreviousIdentityExpires: req.previousIdentityExpires, + LoginIP: req.loginIP, + PinnedIP: pinnedIP, + DisallowReissue: req.disallowReissue, + Renewable: req.renewable, + Generation: req.generation, + BotName: req.botName, + BotInstanceID: req.botInstanceID, + CertificateExtensions: req.checker.CertificateExtensions(), + AllowedResourceIDs: requestedResourcesStr, + ConnectionDiagnosticID: req.connectionDiagnosticID, + PrivateKeyPolicy: attestedKeyPolicy, + DeviceID: req.deviceExtensions.DeviceID, + DeviceAssetTag: req.deviceExtensions.AssetTag, + DeviceCredentialID: req.deviceExtensions.CredentialID, + }, } signedSSHCert, err = a.GenerateUserCert(params) if err != nil { diff --git a/lib/auth/keygen/keygen.go b/lib/auth/keygen/keygen.go index bb77f36012188..5f47b3a90ac16 100644 --- a/lib/auth/keygen/keygen.go +++ b/lib/auth/keygen/keygen.go @@ -23,7 +23,6 @@ import ( "crypto/rand" "fmt" "log/slog" - "strings" "time" "github.com/gravitational/trace" @@ -31,12 +30,11 @@ import ( "golang.org/x/crypto/ssh" "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/utils" ) @@ -129,158 +127,70 @@ func (k *Keygen) GenerateHostCertWithoutValidation(c services.HostCertParams) ([ // GenerateUserCert generates a user ssh certificate with the passed in parameters. // The private key of the CA to sign the certificate must be provided. -func (k *Keygen) GenerateUserCert(c services.UserCertParams) ([]byte, error) { - if err := c.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err, "error validating UserCertParams") +func (k *Keygen) GenerateUserCert(req sshca.UserCertificateRequest) ([]byte, error) { + if err := req.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err, "error validating user certificate request") } - return k.GenerateUserCertWithoutValidation(c) + return k.GenerateUserCertWithoutValidation(req) } // GenerateUserCertWithoutValidation generates a user ssh certificate with the // passed in parameters without validating them. -func (k *Keygen) GenerateUserCertWithoutValidation(c services.UserCertParams) ([]byte, error) { - pubKey, _, _, _, err := ssh.ParseAuthorizedKey(c.PublicUserKey) +func (k *Keygen) GenerateUserCertWithoutValidation(req sshca.UserCertificateRequest) ([]byte, error) { + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(req.PublicUserKey) if err != nil { return nil, trace.Wrap(err) } - validBefore := uint64(ssh.CertTimeInfinity) - if c.TTL != 0 { - b := k.clock.Now().UTC().Add(c.TTL) - validBefore = uint64(b.Unix()) + + // create shallow copy of identity since we want to make some local changes + ident := req.Identity + + // since this method ignores the supplied values for ValidBefore/ValidAfter, avoid confusing by + // rejecting identities where they are set. + if ident.ValidBefore != 0 { + return nil, trace.BadParameter("ValidBefore should not be set in calls to GenerateUserCert") + } + if ident.ValidAfter != 0 { + return nil, trace.BadParameter("ValidAfter should not be set in calls to GenerateUserCert") + } + + // calculate ValidBefore based on the outer request TTL + ident.ValidBefore = uint64(ssh.CertTimeInfinity) + if req.TTL != 0 { + b := k.clock.Now().UTC().Add(req.TTL) + ident.ValidBefore = uint64(b.Unix()) slog.DebugContext( context.TODO(), "Generated user key with expiry.", - "allowed_logins", c.AllowedLogins, - "valid_before_unix_ts", validBefore, + "allowed_logins", ident.AllowedLogins, + "valid_before_unix_ts", ident.ValidBefore, "valid_before", b, ) } - cert := &ssh.Certificate{ - // we have to use key id to identify teleport user - KeyId: c.Username, - ValidPrincipals: c.AllowedLogins, - Key: pubKey, - ValidAfter: uint64(k.clock.Now().UTC().Add(-1 * time.Minute).Unix()), - ValidBefore: validBefore, - CertType: ssh.UserCert, - } - cert.Permissions.Extensions = map[string]string{ - teleport.CertExtensionPermitPTY: "", - } - if c.PermitX11Forwarding { - cert.Permissions.Extensions[teleport.CertExtensionPermitX11Forwarding] = "" - } - if c.PermitAgentForwarding { - cert.Permissions.Extensions[teleport.CertExtensionPermitAgentForwarding] = "" - } - if c.PermitPortForwarding { - cert.Permissions.Extensions[teleport.CertExtensionPermitPortForwarding] = "" - } - if c.MFAVerified != "" { - cert.Permissions.Extensions[teleport.CertExtensionMFAVerified] = c.MFAVerified - } - if !c.PreviousIdentityExpires.IsZero() { - cert.Permissions.Extensions[teleport.CertExtensionPreviousIdentityExpires] = c.PreviousIdentityExpires.Format(time.RFC3339) - } - if c.LoginIP != "" { - cert.Permissions.Extensions[teleport.CertExtensionLoginIP] = c.LoginIP - } - if c.Impersonator != "" { - cert.Permissions.Extensions[teleport.CertExtensionImpersonator] = c.Impersonator - } - if c.DisallowReissue { - cert.Permissions.Extensions[teleport.CertExtensionDisallowReissue] = "" - } - if c.Renewable { - cert.Permissions.Extensions[teleport.CertExtensionRenewable] = "" - } - if c.Generation > 0 { - cert.Permissions.Extensions[teleport.CertExtensionGeneration] = fmt.Sprint(c.Generation) - } - if c.BotName != "" { - cert.Permissions.Extensions[teleport.CertExtensionBotName] = c.BotName - } - if c.BotInstanceID != "" { - cert.Permissions.Extensions[teleport.CertExtensionBotInstanceID] = c.BotInstanceID - } - if c.AllowedResourceIDs != "" { - cert.Permissions.Extensions[teleport.CertExtensionAllowedResources] = c.AllowedResourceIDs - } - if c.ConnectionDiagnosticID != "" { - cert.Permissions.Extensions[teleport.CertExtensionConnectionDiagnosticID] = c.ConnectionDiagnosticID - } - if c.PrivateKeyPolicy != "" { - cert.Permissions.Extensions[teleport.CertExtensionPrivateKeyPolicy] = string(c.PrivateKeyPolicy) - } - if devID := c.DeviceID; devID != "" { - cert.Permissions.Extensions[teleport.CertExtensionDeviceID] = devID - } - if assetTag := c.DeviceAssetTag; assetTag != "" { - cert.Permissions.Extensions[teleport.CertExtensionDeviceAssetTag] = assetTag - } - if credID := c.DeviceCredentialID; credID != "" { - cert.Permissions.Extensions[teleport.CertExtensionDeviceCredentialID] = credID - } - if c.PinnedIP != "" { + // set ValidAfter to be 1 minute in the past + ident.ValidAfter = uint64(k.clock.Now().UTC().Add(-1 * time.Minute).Unix()) + + // if the provided identity is attempting to perform IP pinning, make sure modules are enforced + if ident.PinnedIP != "" { if modules.GetModules().BuildType() != modules.BuildEnterprise { return nil, trace.AccessDenied("source IP pinning is only supported in Teleport Enterprise") } - if cert.CriticalOptions == nil { - cert.CriticalOptions = make(map[string]string) - } - //IPv4, all bits matter - ip := c.PinnedIP + "/32" - if strings.Contains(c.PinnedIP, ":") { - //IPv6 - ip = c.PinnedIP + "/128" - } - cert.CriticalOptions[teleport.CertCriticalOptionSourceAddress] = ip } - for _, extension := range c.CertificateExtensions { - // TODO(lxea): update behavior when non ssh, non extensions are supported. - if extension.Mode != types.CertExtensionMode_EXTENSION || - extension.Type != types.CertExtensionType_SSH { - continue - } - cert.Extensions[extension.Name] = extension.Value + // encode the identity into a certificate + cert, err := ident.Encode(req.CertificateFormat) + if err != nil { + return nil, trace.Wrap(err) } - // Add roles, traits, and route to cluster in the certificate extensions if - // the standard format was requested. Certificate extensions are not included - // legacy SSH certificates due to a bug in OpenSSH <= OpenSSH 7.1: - // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 - if c.CertificateFormat == constants.CertificateFormatStandard { - traits, err := wrappers.MarshalTraits(&c.Traits) - if err != nil { - return nil, trace.Wrap(err) - } - if len(traits) > 0 { - cert.Permissions.Extensions[teleport.CertExtensionTeleportTraits] = string(traits) - } - if len(c.Roles) != 0 { - roles, err := services.MarshalCertRoles(c.Roles) - if err != nil { - return nil, trace.Wrap(err) - } - cert.Permissions.Extensions[teleport.CertExtensionTeleportRoles] = roles - } - if c.RouteToCluster != "" { - cert.Permissions.Extensions[teleport.CertExtensionTeleportRouteToCluster] = c.RouteToCluster - } - if !c.ActiveRequests.IsEmpty() { - requests, err := c.ActiveRequests.Marshal() - if err != nil { - return nil, trace.Wrap(err) - } - cert.Permissions.Extensions[teleport.CertExtensionTeleportActiveRequests] = string(requests) - } - } + // set the public key of the certificate + cert.Key = pubKey - if err := cert.SignCert(rand.Reader, c.CASigner); err != nil { + if err := cert.SignCert(rand.Reader, req.CASigner); err != nil { return nil, trace.Wrap(err) } + return ssh.MarshalAuthorizedKey(cert), nil } diff --git a/lib/auth/keygen/keygen_test.go b/lib/auth/keygen/keygen_test.go index e2d68d91a923e..d6c243b3ee986 100644 --- a/lib/auth/keygen/keygen_test.go +++ b/lib/auth/keygen/keygen_test.go @@ -38,6 +38,7 @@ import ( "github.com/gravitational/teleport/lib/auth/test" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" ) type nativeContext struct { @@ -226,23 +227,24 @@ func TestUserCertCompatibility(t *testing.T) { for i, tc := range tests { comment := fmt.Sprintf("Test %v", i) - userCertificateBytes, err := tt.suite.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: ssh.MarshalAuthorizedKey(caSigner.PublicKey()), - Username: "user", - AllowedLogins: []string{"centos", "root"}, - TTL: time.Hour, - Roles: []string{"foo"}, - CertificateExtensions: []*types.CertExtension{{ - Type: types.CertExtensionType_SSH, - Mode: types.CertExtensionMode_EXTENSION, - Name: "login@github.com", - Value: "hello", + userCertificateBytes, err := tt.suite.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: ssh.MarshalAuthorizedKey(caSigner.PublicKey()), + TTL: time.Hour, + CertificateFormat: tc.inCompatibility, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"centos", "root"}, + Roles: []string{"foo"}, + CertificateExtensions: []*types.CertExtension{{ + Type: types.CertExtensionType_SSH, + Mode: types.CertExtensionMode_EXTENSION, + Name: "login@github.com", + Value: "hello", + }}, + PermitAgentForwarding: true, + PermitPortForwarding: true, }, - }, - CertificateFormat: tc.inCompatibility, - PermitAgentForwarding: true, - PermitPortForwarding: true, }) require.NoError(t, err, comment) diff --git a/lib/auth/test/suite.go b/lib/auth/test/suite.go index 2fc9f0e2351de..082768d8ca281 100644 --- a/lib/auth/test/suite.go +++ b/lib/auth/test/suite.go @@ -95,15 +95,17 @@ func (s *AuthSuite) GenerateUserCert(t *testing.T) { caSigner, err := ssh.ParsePrivateKey(priv) require.NoError(t, err) - cert, err := s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - AllowedLogins: []string{"centos", "root"}, - TTL: time.Hour, - PermitAgentForwarding: true, - PermitPortForwarding: true, - CertificateFormat: constants.CertificateFormatStandard, + cert, err := s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: time.Hour, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"centos", "root"}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + }, }) require.NoError(t, err) @@ -112,59 +114,67 @@ func (s *AuthSuite) GenerateUserCert(t *testing.T) { err = checkCertExpiry(cert, s.Clock.Now().Add(-1*time.Minute), s.Clock.Now().Add(1*time.Hour)) require.NoError(t, err) - cert, err = s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - AllowedLogins: []string{"root"}, - TTL: -20, - PermitAgentForwarding: true, - PermitPortForwarding: true, - CertificateFormat: constants.CertificateFormatStandard, + cert, err = s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: -20, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"root"}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + }, }) require.NoError(t, err) err = checkCertExpiry(cert, s.Clock.Now().Add(-1*time.Minute), s.Clock.Now().Add(apidefaults.MinCertDuration)) require.NoError(t, err) - _, err = s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - AllowedLogins: []string{"root"}, - TTL: 0, - PermitAgentForwarding: true, - PermitPortForwarding: true, - CertificateFormat: constants.CertificateFormatStandard, + _, err = s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: 0, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"root"}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + }, }) require.NoError(t, err) err = checkCertExpiry(cert, s.Clock.Now().Add(-1*time.Minute), s.Clock.Now().Add(apidefaults.MinCertDuration)) require.NoError(t, err) - _, err = s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - AllowedLogins: []string{"root"}, - TTL: time.Hour, - PermitAgentForwarding: true, - PermitPortForwarding: true, - CertificateFormat: constants.CertificateFormatStandard, + _, err = s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: time.Hour, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"root"}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + }, }) require.NoError(t, err) inRoles := []string{"role-1", "role-2"} impersonator := "alice" - cert, err = s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - Impersonator: impersonator, - AllowedLogins: []string{"root"}, - TTL: time.Hour, - PermitAgentForwarding: true, - PermitPortForwarding: true, - CertificateFormat: constants.CertificateFormatStandard, - Roles: inRoles, + cert, err = s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: time.Hour, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + Impersonator: impersonator, + AllowedLogins: []string{"root"}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + Roles: inRoles, + }, }) require.NoError(t, err) parsedCert, err := sshutils.ParseCertificate(cert) @@ -178,15 +188,17 @@ func (s *AuthSuite) GenerateUserCert(t *testing.T) { // Check that MFAVerified and PreviousIdentityExpires are encoded into ssh cert clock := clockwork.NewFakeClock() - cert, err = s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: pub, - Username: "user", - AllowedLogins: []string{"root"}, - TTL: time.Minute, - CertificateFormat: constants.CertificateFormatStandard, - MFAVerified: "mfa-device-id", - PreviousIdentityExpires: clock.Now().Add(time.Hour), + cert, err = s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: pub, + TTL: time.Minute, + CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: "user", + AllowedLogins: []string{"root"}, + MFAVerified: "mfa-device-id", + PreviousIdentityExpires: clock.Now().Add(time.Hour), + }, }) require.NoError(t, err) parsedCert, err = sshutils.ParseCertificate(cert) @@ -202,14 +214,16 @@ func (s *AuthSuite) GenerateUserCert(t *testing.T) { const devID = "deviceid1" const devTag = "devicetag1" const devCred = "devicecred1" - certRaw, err := s.A.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, // Required. - PublicUserKey: pub, // Required. - Username: "llama", // Required. - AllowedLogins: []string{"llama"}, // Required. - DeviceID: devID, - DeviceAssetTag: devTag, - DeviceCredentialID: devCred, + certRaw, err := s.A.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, // Required. + PublicUserKey: pub, // Required. + Identity: sshca.Identity{ + Username: "llama", // Required. + AllowedLogins: []string{"llama"}, // Required. + DeviceID: devID, + DeviceAssetTag: devTag, + DeviceCredentialID: devCred, + }, }) require.NoError(t, err, "GenerateUserCert failed") diff --git a/lib/auth/testauthority/testauthority.go b/lib/auth/testauthority/testauthority.go index 8dae039d9c1f4..b58f9ac27493d 100644 --- a/lib/auth/testauthority/testauthority.go +++ b/lib/auth/testauthority/testauthority.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/lib/auth/keygen" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" ) type Keygen struct { @@ -60,7 +61,7 @@ func (n *Keygen) GenerateHostCert(c services.HostCertParams) ([]byte, error) { return n.GenerateHostCertWithoutValidation(c) } -func (n *Keygen) GenerateUserCert(c services.UserCertParams) ([]byte, error) { +func (n *Keygen) GenerateUserCert(c sshca.UserCertificateRequest) ([]byte, error) { return n.GenerateUserCertWithoutValidation(c) } diff --git a/lib/client/client_store_test.go b/lib/client/client_store_test.go index 331414b2cdeee..7405b1c705c7b 100644 --- a/lib/client/client_store_test.go +++ b/lib/client/client_store_test.go @@ -45,6 +45,7 @@ import ( "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -102,14 +103,16 @@ func (s *testAuthority) makeSignedKeyRing(t *testing.T, idx KeyRingIndex, makeEx caSigner, err := ssh.ParsePrivateKey(CAPriv) require.NoError(t, err) - cert, err := s.keygen.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: sshPriv.MarshalSSHPublicKey(), - Username: idx.Username, - AllowedLogins: allowedLogins, - TTL: ttl, - PermitAgentForwarding: false, - PermitPortForwarding: true, + cert, err := s.keygen.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: sshPriv.MarshalSSHPublicKey(), + TTL: ttl, + Identity: sshca.Identity{ + Username: idx.Username, + AllowedLogins: allowedLogins, + PermitAgentForwarding: false, + PermitPortForwarding: true, + }, }) require.NoError(t, err) diff --git a/lib/client/cluster_client_test.go b/lib/client/cluster_client_test.go index 7a90be3f30d80..e529b4737d1db 100644 --- a/lib/client/cluster_client_test.go +++ b/lib/client/cluster_client_test.go @@ -39,7 +39,7 @@ import ( libmfa "github.com/gravitational/teleport/lib/client/mfa" "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/observability/tracing" - "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/tlsca" ) @@ -390,13 +390,15 @@ func TestIssueUserCertsWithMFA(t *testing.T) { var sshCert, tlsCert []byte var err error if req.SSHPublicKey != nil { - sshCert, err = ca.keygen.GenerateUserCert(services.UserCertParams{ + sshCert, err = ca.keygen.GenerateUserCert(sshca.UserCertificateRequest{ CASigner: caSigner, PublicUserKey: req.SSHPublicKey, TTL: req.Expires.Sub(clock.Now()), - Username: req.Username, CertificateFormat: req.Format, - RouteToCluster: req.RouteToCluster, + Identity: sshca.Identity{ + Username: req.Username, + RouteToCluster: req.RouteToCluster, + }, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/client/identityfile/identity_test.go b/lib/client/identityfile/identity_test.go index 3f52aefe162db..9d8eeb62a894d 100644 --- a/lib/client/identityfile/identity_test.go +++ b/lib/client/identityfile/identity_test.go @@ -46,7 +46,7 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/kube/kubeconfig" - "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/tlsca" ) @@ -108,11 +108,13 @@ func newClientKeyRing(t *testing.T, modifiers ...func(*tlsca.Identity)) *client. caSigner, err := ssh.NewSignerFromKey(signer) require.NoError(t, err) - certificate, err := keygen.GenerateUserCert(services.UserCertParams{ + certificate, err := keygen.GenerateUserCert(sshca.UserCertificateRequest{ CASigner: caSigner, PublicUserKey: ssh.MarshalAuthorizedKey(privateKey.SSHPublicKey()), - Username: "testuser", - AllowedLogins: []string{"testuser"}, + Identity: sshca.Identity{ + Username: "testuser", + AllowedLogins: []string{"testuser"}, + }, }) require.NoError(t, err) diff --git a/lib/client/keyagent_test.go b/lib/client/keyagent_test.go index bbd2ae2677dc7..cbf313591e4d9 100644 --- a/lib/client/keyagent_test.go +++ b/lib/client/keyagent_test.go @@ -50,6 +50,7 @@ import ( "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" ) @@ -751,16 +752,18 @@ func (s *KeyAgentTestSuite) makeKeyRing(t *testing.T, username, proxyHost string sshPub, err := ssh.NewPublicKey(sshKey.Public()) require.NoError(t, err) - certificate, err := testauthority.New().GenerateUserCert(services.UserCertParams{ - CertificateFormat: constants.CertificateFormatStandard, - CASigner: caSigner, - PublicUserKey: ssh.MarshalAuthorizedKey(sshPub), - Username: username, - AllowedLogins: []string{username}, - TTL: ttl, - PermitAgentForwarding: true, - PermitPortForwarding: true, - RouteToCluster: s.clusterName, + certificate, err := testauthority.New().GenerateUserCert(sshca.UserCertificateRequest{ + CertificateFormat: constants.CertificateFormatStandard, + CASigner: caSigner, + PublicUserKey: ssh.MarshalAuthorizedKey(sshPub), + TTL: ttl, + Identity: sshca.Identity{ + Username: username, + AllowedLogins: []string{username}, + PermitAgentForwarding: true, + PermitPortForwarding: true, + RouteToCluster: s.clusterName, + }, }) require.NoError(t, err) diff --git a/lib/reversetunnel/srv_test.go b/lib/reversetunnel/srv_test.go index 327b194ac6ae7..5d4a62e485eab 100644 --- a/lib/reversetunnel/srv_test.go +++ b/lib/reversetunnel/srv_test.go @@ -40,6 +40,7 @@ import ( "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/testauthority" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/utils" ) @@ -104,15 +105,17 @@ func TestServerKeyAuth(t *testing.T) { { desc: "user cert", key: func() ssh.PublicKey { - rawCert, err := ta.GenerateUserCert(services.UserCertParams{ + rawCert, err := ta.GenerateUserCert(sshca.UserCertificateRequest{ CASigner: caSigner, PublicUserKey: pub, - Username: con.User(), - AllowedLogins: []string{con.User()}, - Roles: []string{"dev", "admin"}, - RouteToCluster: "user-cluster-name", CertificateFormat: constants.CertificateFormatStandard, TTL: time.Minute, + Identity: sshca.Identity{ + Username: con.User(), + AllowedLogins: []string{con.User()}, + Roles: []string{"dev", "admin"}, + RouteToCluster: "user-cluster-name", + }, }) require.NoError(t, err) key, _, _, _, err := ssh.ParseAuthorizedKey(rawCert) diff --git a/lib/services/authority.go b/lib/services/authority.go index 74dec10149712..2345342b1195b 100644 --- a/lib/services/authority.go +++ b/lib/services/authority.go @@ -32,9 +32,7 @@ import ( "github.com/jonboulle/clockwork" "golang.org/x/crypto/ssh" - apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/jwt" @@ -321,97 +319,6 @@ func (c HostCertParams) Check() error { return nil } -// UserCertParams defines OpenSSH user certificate parameters -type UserCertParams struct { - // CASigner is the signer that will sign the public key of the user with the CA private key - CASigner ssh.Signer - // PublicUserKey is the public key of the user in SSH authorized_keys format. - PublicUserKey []byte - // TTL defines how long a certificate is valid for - TTL time.Duration - // Username is teleport username - Username string - // Impersonator is set when a user requests certificate for another user - Impersonator string - // AllowedLogins is a list of SSH principals - AllowedLogins []string - // PermitX11Forwarding permits X11 forwarding for this cert - PermitX11Forwarding bool - // PermitAgentForwarding permits agent forwarding for this cert - PermitAgentForwarding bool - // PermitPortForwarding permits port forwarding. - PermitPortForwarding bool - // PermitFileCopying permits the use of SCP/SFTP. - PermitFileCopying bool - // Roles is a list of roles assigned to this user - Roles []string - // CertificateFormat is the format of the SSH certificate. - CertificateFormat string - // RouteToCluster specifies the target cluster - // if present in the certificate, will be used - // to route the requests to - RouteToCluster string - // Traits hold claim data used to populate a role at runtime. - Traits wrappers.Traits - // ActiveRequests tracks privilege escalation requests applied during - // certificate construction. - ActiveRequests RequestIDs - // MFAVerified is the UUID of an MFA device when this Identity was - // confirmed immediately after an MFA check. - MFAVerified string - // PreviousIdentityExpires is the expiry time of the identity/cert that this - // identity/cert was derived from. It is used to determine a session's hard - // deadline in cases where both require_session_mfa and disconnect_expired_cert - // are enabled. See https://github.com/gravitational/teleport/issues/18544. - PreviousIdentityExpires time.Time - // LoginIP is an observed IP of the client on the moment of certificate creation. - LoginIP string - // PinnedIP is an IP from which client must communicate with Teleport. - PinnedIP string - // DisallowReissue flags that any attempt to request new certificates while - // authenticated with this cert should be denied. - DisallowReissue bool - // CertificateExtensions are user configured ssh key extensions - CertificateExtensions []*types.CertExtension - // Renewable indicates this certificate is renewable. - Renewable bool - // Generation counts the number of times a certificate has been renewed. - Generation uint64 - // BotName is set to the name of the bot, if the user is a Machine ID bot user. - // Empty for human users. - BotName string - // BotInstanceID is the unique identifier for the bot instance, if this is a - // Machine ID bot. It is empty for human users. - BotInstanceID string - // AllowedResourceIDs lists the resources the user should be able to access. - AllowedResourceIDs string - // ConnectionDiagnosticID references the ConnectionDiagnostic that we should use to append traces when testing a Connection. - ConnectionDiagnosticID string - // PrivateKeyPolicy is the private key policy supported by this certificate. - PrivateKeyPolicy keys.PrivateKeyPolicy - // DeviceID is the trusted device identifier. - DeviceID string - // DeviceAssetTag is the device inventory identifier. - DeviceAssetTag string - // DeviceCredentialID is the identifier for the credential used by the device - // to authenticate itself. - DeviceCredentialID string -} - -// CheckAndSetDefaults checks the user certificate parameters -func (c *UserCertParams) CheckAndSetDefaults() error { - if c.CASigner == nil { - return trace.BadParameter("CASigner is required") - } - if c.TTL < apidefaults.MinCertDuration { - c.TTL = apidefaults.MinCertDuration - } - if len(c.AllowedLogins) == 0 { - return trace.BadParameter("AllowedLogins are required") - } - return nil -} - // CertPoolFromCertAuthorities returns a certificate pool from the TLS certificates // set up in the certificate authorities list, as well as the number of certificates // that were added to the pool. diff --git a/lib/srv/authhandlers_test.go b/lib/srv/authhandlers_test.go index 78856817654a9..907a3db97b786 100644 --- a/lib/srv/authhandlers_test.go +++ b/lib/srv/authhandlers_test.go @@ -35,7 +35,7 @@ import ( "github.com/gravitational/teleport/lib/auth/testauthority" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/events/eventstest" - "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshca" ) type mockCAandAuthPrefGetter struct { @@ -213,11 +213,13 @@ func TestRBAC(t *testing.T) { privateKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.ECDSAP256) require.NoError(t, err) - c, err := keygen.GenerateUserCert(services.UserCertParams{ + c, err := keygen.GenerateUserCert(sshca.UserCertificateRequest{ CASigner: caSigner, PublicUserKey: ssh.MarshalAuthorizedKey(privateKey.SSHPublicKey()), - Username: "testuser", - AllowedLogins: []string{"testuser"}, + Identity: sshca.Identity{ + Username: "testuser", + AllowedLogins: []string{"testuser"}, + }, }) require.NoError(t, err) @@ -385,16 +387,18 @@ func TestRBACJoinMFA(t *testing.T) { require.NoError(t, err) keygen := testauthority.New() - c, err := keygen.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: privateKey.MarshalSSHPublicKey(), - Username: username, - AllowedLogins: []string{username}, - Traits: wrappers.Traits{ - teleport.TraitInternalPrefix: []string{""}, - }, - Roles: []string{tt.role}, + c, err := keygen.GenerateUserCert(sshca.UserCertificateRequest{ + CASigner: caSigner, + PublicUserKey: privateKey.MarshalSSHPublicKey(), CertificateFormat: constants.CertificateFormatStandard, + Identity: sshca.Identity{ + Username: username, + AllowedLogins: []string{username}, + Traits: wrappers.Traits{ + teleport.TraitInternalPrefix: []string{""}, + }, + Roles: []string{tt.role}, + }, }) require.NoError(t, err) diff --git a/lib/sshca/identity.go b/lib/sshca/identity.go new file mode 100644 index 0000000000000..0e988f244a830 --- /dev/null +++ b/lib/sshca/identity.go @@ -0,0 +1,378 @@ +/* + * 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 . + */ + +// Package sshca specifies interfaces for SSH certificate authorities +package sshca + +import ( + "fmt" + "maps" + "strconv" + "strings" + "time" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/wrappers" + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/services" +) + +// Identity is a user identity. All identity fields map directly to an ssh certificate field. +type Identity struct { + // ValidAfter is the unix timestamp that marks the start time for when the certificate should + // be considered valid. + ValidAfter uint64 + // ValidBefore is the unix timestamp that marks the end time for when the certificate should + // be considered valid. + ValidBefore uint64 + // Username is teleport username + Username string + // Impersonator is set when a user requests certificate for another user + Impersonator string + // AllowedLogins is a list of SSH principals + AllowedLogins []string + // PermitX11Forwarding permits X11 forwarding for this cert + PermitX11Forwarding bool + // PermitAgentForwarding permits agent forwarding for this cert + PermitAgentForwarding bool + // PermitPortForwarding permits port forwarding. + PermitPortForwarding bool + // Roles is a list of roles assigned to this user + Roles []string + // RouteToCluster specifies the target cluster + // if present in the certificate, will be used + // to route the requests to + RouteToCluster string + // Traits hold claim data used to populate a role at runtime. + Traits wrappers.Traits + // ActiveRequests tracks privilege escalation requests applied during + // certificate construction. + ActiveRequests services.RequestIDs + // MFAVerified is the UUID of an MFA device when this Identity was + // confirmed immediately after an MFA check. + MFAVerified string + // PreviousIdentityExpires is the expiry time of the identity/cert that this + // identity/cert was derived from. It is used to determine a session's hard + // deadline in cases where both require_session_mfa and disconnect_expired_cert + // are enabled. See https://github.com/gravitational/teleport/issues/18544. + PreviousIdentityExpires time.Time + // LoginIP is an observed IP of the client on the moment of certificate creation. + LoginIP string + // PinnedIP is an IP from which client must communicate with Teleport. + PinnedIP string + // DisallowReissue flags that any attempt to request new certificates while + // authenticated with this cert should be denied. + DisallowReissue bool + // CertificateExtensions are user configured ssh key extensions (note: this field also + // ends up aggregating all *unknown* extensions during cert parsing, meaning that this + // can sometimes contain fields that were inserted by a newer version of teleport). + CertificateExtensions []*types.CertExtension + // Renewable indicates this certificate is renewable. + Renewable bool + // Generation counts the number of times a certificate has been renewed, with a generation of 1 + // meaning the cert has never been renewed. A generation of zero means the cert's generation is + // not being tracked. + Generation uint64 + // BotName is set to the name of the bot, if the user is a Machine ID bot user. + // Empty for human users. + BotName string + // BotInstanceID is the unique identifier for the bot instance, if this is a + // Machine ID bot. It is empty for human users. + BotInstanceID string + // AllowedResourceIDs lists the resources the user should be able to access. + AllowedResourceIDs string + // ConnectionDiagnosticID references the ConnectionDiagnostic that we should use to append traces when testing a Connection. + ConnectionDiagnosticID string + // PrivateKeyPolicy is the private key policy supported by this certificate. + PrivateKeyPolicy keys.PrivateKeyPolicy + // DeviceID is the trusted device identifier. + DeviceID string + // DeviceAssetTag is the device inventory identifier. + DeviceAssetTag string + // DeviceCredentialID is the identifier for the credential used by the device + // to authenticate itself. + DeviceCredentialID string +} + +// Check performs validation of certain fields in the identity. +func (i *Identity) Check() error { + if len(i.AllowedLogins) == 0 { + return trace.BadParameter("ssh user identity missing allowed logins") + } + + return nil +} + +// Encode encodes the identity into an ssh certificate. Note that the returned certificate is incomplete +// and must be have its public key set before signing. +func (i *Identity) Encode(certFormat string) (*ssh.Certificate, error) { + validBefore := i.ValidBefore + if validBefore == 0 { + validBefore = uint64(ssh.CertTimeInfinity) + } + validAfter := i.ValidAfter + if validAfter == 0 { + validAfter = uint64(time.Now().UTC().Add(-1 * time.Minute).Unix()) + } + cert := &ssh.Certificate{ + // we have to use key id to identify teleport user + KeyId: i.Username, + ValidPrincipals: i.AllowedLogins, + ValidAfter: validAfter, + ValidBefore: validBefore, + CertType: ssh.UserCert, + } + cert.Permissions.Extensions = map[string]string{ + teleport.CertExtensionPermitPTY: "", + } + + if i.PermitX11Forwarding { + cert.Permissions.Extensions[teleport.CertExtensionPermitX11Forwarding] = "" + } + if i.PermitAgentForwarding { + cert.Permissions.Extensions[teleport.CertExtensionPermitAgentForwarding] = "" + } + if i.PermitPortForwarding { + cert.Permissions.Extensions[teleport.CertExtensionPermitPortForwarding] = "" + } + if i.MFAVerified != "" { + cert.Permissions.Extensions[teleport.CertExtensionMFAVerified] = i.MFAVerified + } + if !i.PreviousIdentityExpires.IsZero() { + cert.Permissions.Extensions[teleport.CertExtensionPreviousIdentityExpires] = i.PreviousIdentityExpires.Format(time.RFC3339) + } + if i.LoginIP != "" { + cert.Permissions.Extensions[teleport.CertExtensionLoginIP] = i.LoginIP + } + if i.Impersonator != "" { + cert.Permissions.Extensions[teleport.CertExtensionImpersonator] = i.Impersonator + } + if i.DisallowReissue { + cert.Permissions.Extensions[teleport.CertExtensionDisallowReissue] = "" + } + if i.Renewable { + cert.Permissions.Extensions[teleport.CertExtensionRenewable] = "" + } + if i.Generation > 0 { + cert.Permissions.Extensions[teleport.CertExtensionGeneration] = fmt.Sprint(i.Generation) + } + if i.BotName != "" { + cert.Permissions.Extensions[teleport.CertExtensionBotName] = i.BotName + } + if i.BotInstanceID != "" { + cert.Permissions.Extensions[teleport.CertExtensionBotInstanceID] = i.BotInstanceID + } + if i.AllowedResourceIDs != "" { + cert.Permissions.Extensions[teleport.CertExtensionAllowedResources] = i.AllowedResourceIDs + } + if i.ConnectionDiagnosticID != "" { + cert.Permissions.Extensions[teleport.CertExtensionConnectionDiagnosticID] = i.ConnectionDiagnosticID + } + if i.PrivateKeyPolicy != "" { + cert.Permissions.Extensions[teleport.CertExtensionPrivateKeyPolicy] = string(i.PrivateKeyPolicy) + } + if devID := i.DeviceID; devID != "" { + cert.Permissions.Extensions[teleport.CertExtensionDeviceID] = devID + } + if assetTag := i.DeviceAssetTag; assetTag != "" { + cert.Permissions.Extensions[teleport.CertExtensionDeviceAssetTag] = assetTag + } + if credID := i.DeviceCredentialID; credID != "" { + cert.Permissions.Extensions[teleport.CertExtensionDeviceCredentialID] = credID + } + + if i.PinnedIP != "" { + if cert.CriticalOptions == nil { + cert.CriticalOptions = make(map[string]string) + } + // IPv4, all bits matter + ip := i.PinnedIP + "/32" + if strings.Contains(i.PinnedIP, ":") { + // IPv6 + ip = i.PinnedIP + "/128" + } + cert.CriticalOptions[teleport.CertCriticalOptionSourceAddress] = ip + } + + for _, extension := range i.CertificateExtensions { + // TODO(lxea): update behavior when non ssh, non extensions are supported. + if extension.Mode != types.CertExtensionMode_EXTENSION || + extension.Type != types.CertExtensionType_SSH { + continue + } + cert.Extensions[extension.Name] = extension.Value + } + + // Add roles, traits, and route to cluster in the certificate extensions if + // the standard format was requested. Certificate extensions are not included + // legacy SSH certificates due to a bug in OpenSSH <= OpenSSH 7.1: + // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 + if certFormat == constants.CertificateFormatStandard { + traits, err := wrappers.MarshalTraits(&i.Traits) + if err != nil { + return nil, trace.Wrap(err) + } + if len(traits) > 0 { + cert.Permissions.Extensions[teleport.CertExtensionTeleportTraits] = string(traits) + } + if len(i.Roles) != 0 { + roles, err := services.MarshalCertRoles(i.Roles) + if err != nil { + return nil, trace.Wrap(err) + } + cert.Permissions.Extensions[teleport.CertExtensionTeleportRoles] = roles + } + if i.RouteToCluster != "" { + cert.Permissions.Extensions[teleport.CertExtensionTeleportRouteToCluster] = i.RouteToCluster + } + if !i.ActiveRequests.IsEmpty() { + requests, err := i.ActiveRequests.Marshal() + if err != nil { + return nil, trace.Wrap(err) + } + cert.Permissions.Extensions[teleport.CertExtensionTeleportActiveRequests] = string(requests) + } + } + + return cert, nil +} + +// DecodeIdentity decodes an ssh certificate into an identity. +func DecodeIdentity(cert *ssh.Certificate) (*Identity, error) { + if cert.CertType != ssh.UserCert { + return nil, trace.BadParameter("DecodeIdentity intended for use with user certs, got %v", cert.CertType) + } + ident := &Identity{ + Username: cert.KeyId, + AllowedLogins: cert.ValidPrincipals, + ValidAfter: cert.ValidAfter, + ValidBefore: cert.ValidBefore, + } + + // clone the extension map and remove entries from the clone as they are processed so + // that we can easily aggregate the remainder into the CertificateExtensions field. + extensions := maps.Clone(cert.Extensions) + + takeExtension := func(name string) (value string, ok bool) { + v, ok := extensions[name] + if !ok { + return "", false + } + delete(extensions, name) + return v, true + } + + takeValue := func(name string) string { + value, _ := takeExtension(name) + return value + } + + takeBool := func(name string) bool { + _, ok := takeExtension(name) + return ok + } + + // ignore the permit pty extension, it's always set + _, _ = takeExtension(teleport.CertExtensionPermitPTY) + + ident.PermitX11Forwarding = takeBool(teleport.CertExtensionPermitX11Forwarding) + ident.PermitAgentForwarding = takeBool(teleport.CertExtensionPermitAgentForwarding) + ident.PermitPortForwarding = takeBool(teleport.CertExtensionPermitPortForwarding) + ident.MFAVerified = takeValue(teleport.CertExtensionMFAVerified) + + if v, ok := takeExtension(teleport.CertExtensionPreviousIdentityExpires); ok { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return nil, trace.BadParameter("failed to parse value %q for extension %q as RFC3339 timestamp: %v", v, teleport.CertExtensionPreviousIdentityExpires, err) + } + ident.PreviousIdentityExpires = t + } + + ident.LoginIP = takeValue(teleport.CertExtensionLoginIP) + ident.Impersonator = takeValue(teleport.CertExtensionImpersonator) + ident.DisallowReissue = takeBool(teleport.CertExtensionDisallowReissue) + ident.Renewable = takeBool(teleport.CertExtensionRenewable) + + if v, ok := takeExtension(teleport.CertExtensionGeneration); ok { + i, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return nil, trace.BadParameter("failed to parse value %q for extension %q as uint64: %v", v, teleport.CertExtensionGeneration, err) + } + ident.Generation = i + } + + ident.BotName = takeValue(teleport.CertExtensionBotName) + ident.BotInstanceID = takeValue(teleport.CertExtensionBotInstanceID) + ident.AllowedResourceIDs = takeValue(teleport.CertExtensionAllowedResources) + ident.ConnectionDiagnosticID = takeValue(teleport.CertExtensionConnectionDiagnosticID) + ident.PrivateKeyPolicy = keys.PrivateKeyPolicy(takeValue(teleport.CertExtensionPrivateKeyPolicy)) + ident.DeviceID = takeValue(teleport.CertExtensionDeviceID) + ident.DeviceAssetTag = takeValue(teleport.CertExtensionDeviceAssetTag) + ident.DeviceCredentialID = takeValue(teleport.CertExtensionDeviceCredentialID) + + if v, ok := cert.CriticalOptions[teleport.CertCriticalOptionSourceAddress]; ok { + parts := strings.Split(v, "/") + if len(parts) != 2 { + return nil, trace.BadParameter("failed to parse value %q for critical option %q as CIDR", v, teleport.CertCriticalOptionSourceAddress) + } + ident.PinnedIP = parts[0] + } + + if v, ok := takeExtension(teleport.CertExtensionTeleportTraits); ok { + var traits wrappers.Traits + if err := wrappers.UnmarshalTraits([]byte(v), &traits); err != nil { + return nil, trace.BadParameter("failed to unmarshal value %q for extension %q as traits: %v", v, teleport.CertExtensionTeleportTraits, err) + } + ident.Traits = traits + } + + if v, ok := takeExtension(teleport.CertExtensionTeleportRoles); ok { + roles, err := services.UnmarshalCertRoles(v) + if err != nil { + return nil, trace.BadParameter("failed to unmarshal value %q for extension %q as roles: %v", v, teleport.CertExtensionTeleportRoles, err) + } + ident.Roles = roles + } + + ident.RouteToCluster = takeValue(teleport.CertExtensionTeleportRouteToCluster) + + if v, ok := takeExtension(teleport.CertExtensionTeleportActiveRequests); ok { + var requests services.RequestIDs + if err := requests.Unmarshal([]byte(v)); err != nil { + return nil, trace.BadParameter("failed to unmarshal value %q for extension %q as active requests: %v", v, teleport.CertExtensionTeleportActiveRequests, err) + } + ident.ActiveRequests = requests + } + + // aggregate all remaining extensions into the CertificateExtensions field + for name, value := range extensions { + ident.CertificateExtensions = append(ident.CertificateExtensions, &types.CertExtension{ + Name: name, + Value: value, + Type: types.CertExtensionType_SSH, + Mode: types.CertExtensionMode_EXTENSION, + }) + } + + return ident, nil +} diff --git a/lib/sshca/identity_test.go b/lib/sshca/identity_test.go new file mode 100644 index 0000000000000..b7e4463ffc1e2 --- /dev/null +++ b/lib/sshca/identity_test.go @@ -0,0 +1,95 @@ +/* + * 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 . + */ + +// Package sshca specifies interfaces for SSH certificate authorities +package sshca + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/wrappers" + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils/testutils" +) + +func TestIdentityConversion(t *testing.T) { + ident := &Identity{ + ValidAfter: 1, + ValidBefore: 2, + Username: "user", + Impersonator: "impersonator", + AllowedLogins: []string{"login1", "login2"}, + PermitX11Forwarding: true, + PermitAgentForwarding: true, + PermitPortForwarding: true, + Roles: []string{"role1", "role2"}, + RouteToCluster: "cluster", + Traits: wrappers.Traits{"trait1": []string{"value1"}, "trait2": []string{"value2"}}, + ActiveRequests: services.RequestIDs{ + AccessRequests: []string{uuid.NewString()}, + }, + MFAVerified: "mfa", + PreviousIdentityExpires: time.Unix(12345, 0), + LoginIP: "127.0.0.1", + PinnedIP: "127.0.0.1", + DisallowReissue: true, + CertificateExtensions: []*types.CertExtension{&types.CertExtension{ + Name: "extname", + Value: "extvalue", + Type: types.CertExtensionType_SSH, + Mode: types.CertExtensionMode_EXTENSION, + }}, + Renewable: true, + Generation: 3, + BotName: "bot", + BotInstanceID: "instance", + AllowedResourceIDs: "resource", + ConnectionDiagnosticID: "diag", + PrivateKeyPolicy: keys.PrivateKeyPolicy("policy"), + DeviceID: "device", + DeviceAssetTag: "asset", + DeviceCredentialID: "cred", + } + + ignores := []string{ + "CertExtension.Type", // only currently defined enum variant is a zero value + "CertExtension.Mode", // only currently defined enum variant is a zero value + // TODO(fspmarshall): figure out a mechanism for making ignore of grpc fields more convenient + "CertExtension.XXX_NoUnkeyedLiteral", + "CertExtension.XXX_unrecognized", + "CertExtension.XXX_sizecache", + } + + require.True(t, testutils.ExhaustiveNonEmpty(ident, ignores...), "empty=%+v", testutils.FindAllEmpty(ident, ignores...)) + + cert, err := ident.Encode(constants.CertificateFormatStandard) + require.NoError(t, err) + + ident2, err := DecodeIdentity(cert) + require.NoError(t, err) + + require.Empty(t, cmp.Diff(ident, ident2)) +} diff --git a/lib/sshca/sshca.go b/lib/sshca/sshca.go index 5e9e3f548f853..15f5dcf6c1aeb 100644 --- a/lib/sshca/sshca.go +++ b/lib/sshca/sshca.go @@ -20,6 +20,12 @@ package sshca import ( + "time" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/lib/services" ) @@ -33,5 +39,34 @@ type Authority interface { // GenerateUserCert generates user ssh certificate, it takes pkey as a signing // private key (user certificate authority) - GenerateUserCert(certParams services.UserCertParams) ([]byte, error) + GenerateUserCert(UserCertificateRequest) ([]byte, error) +} + +// UserCertificateRequest is a request to generate a new ssh user certificate. +type UserCertificateRequest struct { + // CASigner is the signer that will sign the public key of the user with the CA private key + CASigner ssh.Signer + // PublicUserKey is the public key of the user in SSH authorized_keys format. + PublicUserKey []byte + // TTL defines how long a certificate is valid for (if specified, ValidAfter/ValidBefore within the + // identity must not be set). + TTL time.Duration + // CertificateFormat is the format of the SSH certificate. + CertificateFormat string + // Identity is the user identity to be encoded in the certificate. + Identity Identity +} + +func (r *UserCertificateRequest) CheckAndSetDefaults() error { + if r.CASigner == nil { + return trace.BadParameter("ssh user certificate request missing ca signer") + } + if r.TTL < apidefaults.MinCertDuration { + r.TTL = apidefaults.MinCertDuration + } + if err := r.Identity.Check(); err != nil { + return trace.Wrap(err) + } + + return nil }