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
}