diff --git a/command/ca/bootstrap.go b/command/ca/bootstrap.go index 1d28f3b93..e9a9a54a2 100644 --- a/command/ca/bootstrap.go +++ b/command/ca/bootstrap.go @@ -20,7 +20,7 @@ func bootstrapCommand() cli.Command { UsageText: `**step ca bootstrap** [**--ca-url**=] [**--fingerprint**=] [**--install**] [**--team**=] [**--authority**=] [**--team-url**=] [**--redirect-url**=] -[**--context**=] [**--profile**=] +[**--context**=] [**--fallback-context**=] [**--profile**=] [**--authority**=] [**--team-authority**=]`, Description: `**step ca bootstrap** downloads the root certificate from the certificate authority and sets up the current environment to use it. @@ -77,6 +77,7 @@ $ step ca bootstrap --team superteam --team-url https://config.example.com/<> flags.Context, flags.ContextProfile, flags.ContextAuthority, + flags.FallbackContext, flags.HiddenNoContext, }, } @@ -90,12 +91,18 @@ func bootstrapAction(ctx *cli.Context) error { fingerprint := strings.TrimSpace(ctx.String("fingerprint")) team := ctx.String("team") teamAuthority := ctx.String("team-authority") + contextName := ctx.String("context") + fallbackContextName := ctx.String("fallback-context") switch { case team != "" && caURL != "": return errs.IncompatibleFlagWithFlag(ctx, "team", "ca-url") case team != "" && fingerprint != "": return errs.IncompatibleFlagWithFlag(ctx, "team", "fingerprint") + case fallbackContextName != "" && contextName == "": + return errs.RequiredWithFlag(ctx, "fallback-context", "context") + case fallbackContextName != "" && contextName == fallbackContextName: + return errs.IncompatibleFlagValues(ctx, "fallback-context", fallbackContextName, "context", contextName) case team != "" && teamAuthority != "": return cautils.BootstrapTeamAuthority(ctx, team, teamAuthority) case team != "": diff --git a/command/ssh/checkHost.go b/command/ssh/checkHost.go index 7a7ad45d7..1eccad5d4 100644 --- a/command/ssh/checkHost.go +++ b/command/ssh/checkHost.go @@ -54,6 +54,7 @@ $ step ssh check-host internal.smallstep.com flags.CaURL, flags.Root, flags.Context, + flags.FallbackContext, }, } } @@ -71,7 +72,7 @@ func checkHostAction(ctx *cli.Context) error { } version, err := client.Version() if err != nil { - return contactAdminErr(errors.Wrap(err, "error retrieving client version info")) + return runOnFallbackContext(ctx, contactAdminErr(errors.Wrap(err, "error retrieving client version info"))) } var ( @@ -101,8 +102,7 @@ func checkHostAction(ctx *cli.Context) error { resp, err := client.SSHCheckHost(hostname, tok) if err != nil { - return caErrs.Wrap(http.StatusInternalServerError, err, - "error checking ssh host eligibility") + return runOnFallbackContext(ctx, caErrs.Wrap(http.StatusInternalServerError, err, "error checking ssh host eligibility")) } if isVerbose { diff --git a/command/ssh/config.go b/command/ssh/config.go index 451cd2efe..198ed45cd 100644 --- a/command/ssh/config.go +++ b/command/ssh/config.go @@ -34,7 +34,7 @@ func configCommand() cli.Command { [**--team**=] [**--team-authority**=] [**--host**] [**--set**=] [**--set-file**=] [**--dry-run**] [**--roots**] [**--federation**] [**--console**] [**--force**] [**--offline**] [**--ca-config**=] -[**--ca-url**=] [**--root**=] [**--context**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--fallback-context**=] [**--authority**=] [**--profile**=]`, Description: `**step ssh config** configures SSH to be used with certificates. It also supports flags to inspect the root certificates used to sign the certificates. @@ -104,6 +104,7 @@ times to set multiple variables.`, }, flags.ContextProfile, flags.ContextAuthority, + flags.FallbackContext, flags.HiddenNoContext, }, } @@ -115,6 +116,8 @@ func configAction(ctx *cli.Context) (recoverErr error) { isRoots := ctx.Bool("roots") isFederation := ctx.Bool("federation") sets := ctx.StringSlice("set") + contextName := ctx.String("context") + fallbackContextName := ctx.String("fallback-context") switch { case team != "" && isHost: @@ -131,6 +134,10 @@ func configAction(ctx *cli.Context) (recoverErr error) { return errs.IncompatibleFlagWithFlag(ctx, "roots", "set") case isFederation && len(sets) > 0: return errs.IncompatibleFlagWithFlag(ctx, "federation", "set") + case fallbackContextName != "" && contextName == "": + return errs.RequiredWithFlag(ctx, "fallback-context", "context") + case fallbackContextName != "" && contextName == fallbackContextName: + return errs.IncompatibleFlagValues(ctx, "fallback-context", fallbackContextName, "context", contextName) } // Bootstrap Authority diff --git a/command/ssh/proxycommand.go b/command/ssh/proxycommand.go index fe35cf187..bdcc33c6f 100644 --- a/command/ssh/proxycommand.go +++ b/command/ssh/proxycommand.go @@ -1,24 +1,18 @@ package ssh import ( - "crypto" "io" "net" "os" "strings" "sync" - "time" "github.com/pkg/errors" "github.com/urfave/cli" - "golang.org/x/crypto/ssh" "github.com/smallstep/certificates/api" - "github.com/smallstep/certificates/authority/provisioner" - "github.com/smallstep/certificates/ca" "github.com/smallstep/cli-utils/command" "github.com/smallstep/cli-utils/errs" - "go.step.sm/crypto/keyutil" "github.com/smallstep/cli/exec" "github.com/smallstep/cli/flags" @@ -36,7 +30,8 @@ func proxycommandCommand() cli.Command { UsageText: `**step ssh proxycommand** [**--provisioner**=] [**--set**=] [**--set-file**=] [**--console**] [**--offline**] [**--ca-config**=] -[**--ca-url**=] [**--root**=] [**--context**=]`, +[**--ca-url**=] [**--root**=] +[**--context**=] [**--fallback-context**=]`, Description: `**step ssh proxycommand** looks into the host registry and proxies the ssh connection according to its configuration. This command is used in the ssh client config with keyword. @@ -64,6 +59,7 @@ This command will add the user to the ssh-agent if necessary. flags.CaURL, flags.Root, flags.Context, + flags.FallbackContext, }, } } @@ -99,121 +95,7 @@ func proxycommandAction(ctx *cli.Context) error { // doLoginIfNeeded check if the user is logged in looking at the ssh agent, if // it's not it will do the login flow. func doLoginIfNeeded(ctx *cli.Context, subject string) error { - templateData, err := flags.ParseTemplateData(ctx) - if err != nil { - return err - } - - agent, err := sshutil.DialAgent() - if err != nil { - return err - } - - client, err := cautils.NewClient(ctx) - if err != nil { - return err - } - - // Check if a user key exists - if roots, err := client.SSHRoots(); err == nil && len(roots.UserKeys) > 0 { - userKeys := make([]ssh.PublicKey, len(roots.UserKeys)) - for i, uk := range roots.UserKeys { - userKeys[i] = uk.PublicKey - } - exists, err := agent.HasKeys(sshutil.WithSignatureKey(userKeys), sshutil.WithRemoveExpiredCerts(time.Now())) - if err != nil { - return err - } - if exists { - return nil - } - } - - // Do login flow - flow, err := cautils.NewCertificateFlow(ctx) - if err != nil { - return err - } - - // There's not need to sanitize the principal, it should come from ssh. - principals := []string{subject} - - // Make sure the validAfter is in the past. It avoids `Certificate - // invalid: not yet valid` errors if the times are not in sync - // perfectly. - validAfter := provisioner.NewTimeDuration(time.Now().Add(-1 * time.Minute)) - validBefore := provisioner.TimeDuration{} - - token, err := flow.GenerateSSHToken(ctx, subject, cautils.SSHUserSignType, principals, validAfter, validBefore) - if err != nil { - return err - } - - // NOTE: For OIDC tokens the subject should always be the email. The - // provisioner is responsible for loading and setting the principals with - // the application of an Identity function. - if email, ok := tokenEmail(token); ok { - subject = email - } - - caClient, err := flow.GetClient(ctx, token) - if err != nil { - return err - } - - version, err := caClient.Version() - if err != nil { - return err - } - - // Generate identity certificate (x509) if necessary - var identityCSR api.CertificateRequest - var identityKey crypto.PrivateKey - if version.RequireClientAuthentication { - csr, key, err := ca.CreateIdentityRequest(subject) - if err != nil { - return err - } - identityCSR = *csr - identityKey = key - } - - // Generate keypair - pub, priv, err := keyutil.GenerateDefaultKeyPair() - if err != nil { - return err - } - - sshPub, err := ssh.NewPublicKey(pub) - if err != nil { - return errors.Wrap(err, "error creating public key") - } - - // Sign certificate in the CA - resp, err := caClient.SSHSign(&api.SSHSignRequest{ - PublicKey: sshPub.Marshal(), - OTT: token, - Principals: principals, - CertType: provisioner.SSHUserCert, - KeyID: subject, - ValidAfter: validAfter, - ValidBefore: validBefore, - IdentityCSR: identityCSR, - TemplateData: templateData, - }) - if err != nil { - return err - } - - // Write x509 identity certificate - if version.RequireClientAuthentication { - if err := ca.WriteDefaultIdentity(resp.IdentityCertificate, identityKey); err != nil { - return err - } - } - - // Add certificate and private key to agent - return agent.AddCertificate(subject, resp.Certificate.Certificate, priv) + return loginIfNeeded(ctx, subject, withRetryFunc(runOnFallbackContext)) } func getBastion(ctx *cli.Context, user, host string) (*api.SSHBastionResponse, error) { diff --git a/command/ssh/ssh.go b/command/ssh/ssh.go index 24a4e8d87..2d9a2c62f 100644 --- a/command/ssh/ssh.go +++ b/command/ssh/ssh.go @@ -1,8 +1,13 @@ package ssh import ( + "crypto" + "fmt" "net/http" + "os" + "slices" "strings" + "time" "github.com/pkg/errors" "github.com/urfave/cli" @@ -13,9 +18,11 @@ import ( "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/errs" "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/step" "github.com/smallstep/cli-utils/ui" "go.step.sm/crypto/keyutil" + "github.com/smallstep/cli/exec" "github.com/smallstep/cli/flags" "github.com/smallstep/cli/internal/sshutil" "github.com/smallstep/cli/token" @@ -161,6 +168,171 @@ private key so that the pair can be added to an SSH Agent.`, } ) +type loginOptions struct { + retryFunc func(*cli.Context, error) error +} + +type loginOption func(*loginOptions) + +func withRetryFunc(fn func(*cli.Context, error) error) loginOption { + return func(lo *loginOptions) { + lo.retryFunc = fn + } +} + +func runOnFallbackContext(ctx *cli.Context, err error) error { + var ( + contexts = step.Contexts() + current = contexts.GetCurrent() + ) + + if name := ctx.String("fallback-context"); name != "" && name != current.Name { + if _, ok := contexts.Get(name); !ok { + return fmt.Errorf("error loading fallback context %q", name) + } + + arg0, err := os.Executable() + if err != nil || arg0 == "" { + arg0 = os.Args[0] + } + + args := slices.Clone(os.Args[1:]) + args = append(args, "--context", name) + exec.Exec(arg0, args...) + } + + return err +} + +// loginIfNeeded check if the user is logged in looking at the ssh agent, if +// it's not it will do the login flow. +func loginIfNeeded(ctx *cli.Context, subject string, opts ...loginOption) error { + o := &loginOptions{ + retryFunc: func(_ *cli.Context, err error) error { + return err + }, + } + for _, fn := range opts { + fn(o) + } + + templateData, err := flags.ParseTemplateData(ctx) + if err != nil { + return err + } + + agent, err := sshutil.DialAgent() + if err != nil { + return err + } + + client, err := cautils.NewClient(ctx) + if err != nil { + return o.retryFunc(ctx, err) + } + + // Check if a user key exists + if roots, err := client.SSHRoots(); err == nil && len(roots.UserKeys) > 0 { + userKeys := make([]ssh.PublicKey, len(roots.UserKeys)) + for i, uk := range roots.UserKeys { + userKeys[i] = uk.PublicKey + } + exists, err := agent.HasKeys(sshutil.WithSignatureKey(userKeys), sshutil.WithRemoveExpiredCerts(time.Now())) + if err != nil { + return err + } + if exists { + return nil + } + } + + // Do login flow + flow, err := cautils.NewCertificateFlow(ctx) + if err != nil { + return o.retryFunc(ctx, err) + } + + // There's not need to sanitize the principal, it should come from ssh. + principals := []string{subject} + + // Make sure the validAfter is in the past. It avoids `Certificate + // invalid: not yet valid` errors if the times are not in sync + // perfectly. + validAfter := provisioner.NewTimeDuration(time.Now().Add(-1 * time.Minute)) + validBefore := provisioner.TimeDuration{} + + token, err := flow.GenerateSSHToken(ctx, subject, cautils.SSHUserSignType, principals, validAfter, validBefore) + if err != nil { + return o.retryFunc(ctx, err) + } + + // NOTE: For OIDC tokens the subject should always be the email. The + // provisioner is responsible for loading and setting the principals with + // the application of an Identity function. + if email, ok := tokenEmail(token); ok { + subject = email + } + + caClient, err := flow.GetClient(ctx, token) + if err != nil { + return o.retryFunc(ctx, err) + } + + version, err := caClient.Version() + if err != nil { + return o.retryFunc(ctx, err) + } + + // Generate identity certificate (x509) if necessary + var identityCSR api.CertificateRequest + var identityKey crypto.PrivateKey + if version.RequireClientAuthentication { + csr, key, err := ca.CreateIdentityRequest(subject) + if err != nil { + return err + } + identityCSR = *csr + identityKey = key + } + + // Generate keypair + pub, priv, err := keyutil.GenerateDefaultKeyPair() + if err != nil { + return err + } + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return errors.Wrap(err, "error creating public key") + } + + // Sign certificate in the CA + resp, err := caClient.SSHSign(&api.SSHSignRequest{ + PublicKey: sshPub.Marshal(), + OTT: token, + Principals: principals, + CertType: provisioner.SSHUserCert, + KeyID: subject, + ValidAfter: validAfter, + ValidBefore: validBefore, + IdentityCSR: identityCSR, + TemplateData: templateData, + }) + if err != nil { + return o.retryFunc(ctx, err) + } + + // Write x509 identity certificate + if version.RequireClientAuthentication { + if err := ca.WriteDefaultIdentity(resp.IdentityCertificate, identityKey); err != nil { + return err + } + } + + // Add certificate and private key to agent + return agent.AddCertificate(subject, resp.Certificate.Certificate, priv) +} + func loginOnUnauthorized(ctx *cli.Context) (ca.RetryFunc, error) { templateData, err := flags.ParseTemplateData(ctx) if err != nil { diff --git a/flags/flags.go b/flags/flags.go index 95495200f..beb3311bf 100644 --- a/flags/flags.go +++ b/flags/flags.go @@ -261,7 +261,7 @@ generating key.`, Hidden: true, } - // Context is a cli.Flag used to select a a context name. + // Context is a cli.Flag used to select a context name. Context = cli.StringFlag{ Name: "context", Usage: "The context to apply for the given command.", @@ -279,6 +279,12 @@ generating key.`, Usage: `The that will serve as the authority name for the context.`, } + // FallbackContext is a cli.flat to select a fallback context. + FallbackContext = cli.StringFlag{ + Name: "fallback-context", + Usage: "The context to use as a fallback for the given command.", + } + // Offline is a cli.Flag used to activate the offline flow. Offline = cli.BoolFlag{ Name: "offline", diff --git a/utils/cautils/bootstrap.go b/utils/cautils/bootstrap.go index a8929bc84..552ddeedb 100644 --- a/utils/cautils/bootstrap.go +++ b/utils/cautils/bootstrap.go @@ -91,6 +91,7 @@ type bootstrapConfig struct { Fingerprint string `json:"fingerprint"` Root string `json:"root"` Redirect string `json:"redirect-url,omitempty"` + FallbackContext string `json:"fallback-context,omitempty"` Provisioner string `json:"provisioner,omitempty"` MinPasswordLength int `json:"min-password-length,omitempty"` } @@ -112,6 +113,7 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt return errors.Wrap(err, "error downloading root certificate") } + var fallbackContext string if UseContext(ctx) { ctxName := ctx.String("context") if ctxName == "" { @@ -125,6 +127,7 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt if ctxProfile == "" { ctxProfile = ctxName } + fallbackContext = ctx.String("fallback-context") if err := step.Contexts().Add(&step.Context{ Name: ctxName, Profile: ctxProfile, @@ -168,10 +171,11 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt // Serialize defaults.json bootConf := bootstrapConfig{ - CA: caURL, - Fingerprint: fingerprint, - Root: pki.GetRootCAPath(), - Redirect: bc.redirectURL, + CA: caURL, + Fingerprint: fingerprint, + Root: pki.GetRootCAPath(), + Redirect: bc.redirectURL, + FallbackContext: fallbackContext, } if bc.minPasswordLength > 0 { bootConf.MinPasswordLength = bc.minPasswordLength