Skip to content

Commit 88f1618

Browse files
authored
Merge pull request smallstep#1558 from adantop/feat/support-gcp-ssh-user-certs-opt-2
Allowing GCP provisioner to issue SSH User Certificates - Option 2
2 parents d25289a + bedb040 commit 88f1618

File tree

5 files changed

+141
-27
lines changed

5 files changed

+141
-27
lines changed

api/ssh.go

+1
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ func SSHSign(w http.ResponseWriter, r *http.Request) {
292292

293293
ctx := provisioner.NewContextWithMethod(r.Context(), provisioner.SSHSignMethod)
294294
ctx = provisioner.NewContextWithToken(ctx, body.OTT)
295+
ctx = provisioner.NewContextWithCertType(ctx, opts.CertType)
295296

296297
a := mustAuthority(ctx)
297298
signOpts, err := a.Authorize(ctx, body.OTT)

authority/provisioner/gcp.go

+87-19
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs"
3030
// gcpIdentityURL is the base url for the identity document in GCP.
3131
const gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity"
3232

33+
// DefaultDisableSSHCAHost is the default value for SSH Host CA used when DisableSSHCAHost is not set
34+
var DefaultDisableSSHCAHost = false
35+
36+
// DefaultDisableSSHCAUser is the default value for SSH User CA used when DisableSSHCAUser is not set
37+
var DefaultDisableSSHCAUser = true
38+
3339
// gcpPayload extends jwt.Claims with custom GCP attributes.
3440
type gcpPayload struct {
3541
jose.Claims
@@ -89,6 +95,8 @@ type GCP struct {
8995
ProjectIDs []string `json:"projectIDs"`
9096
DisableCustomSANs bool `json:"disableCustomSANs"`
9197
DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"`
98+
DisableSSHCAUser *bool `json:"disableSSHCAUser,omitempty"`
99+
DisableSSHCAHost *bool `json:"disableSSHCAHost,omitempty"`
92100
InstanceAge Duration `json:"instanceAge,omitempty"`
93101
Claims *Claims `json:"claims,omitempty"`
94102
Options *Options `json:"options,omitempty"`
@@ -199,6 +207,14 @@ func (p *GCP) GetIdentityToken(subject, caURL string) (string, error) {
199207

200208
// Init validates and initializes the GCP provisioner.
201209
func (p *GCP) Init(config Config) (err error) {
210+
if p.DisableSSHCAHost == nil {
211+
p.DisableSSHCAHost = &DefaultDisableSSHCAHost
212+
}
213+
214+
if p.DisableSSHCAUser == nil {
215+
p.DisableSSHCAUser = &DefaultDisableSSHCAUser
216+
}
217+
202218
switch {
203219
case p.Type == "":
204220
return errors.New("provisioner type cannot be empty")
@@ -387,31 +403,41 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) {
387403
}
388404

389405
// AuthorizeSSHSign returns the list of SignOption for a SignSSH request.
390-
func (p *GCP) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, error) {
391-
if !p.ctl.Claimer.IsSSHCAEnabled() {
392-
return nil, errs.Unauthorized("gcp.AuthorizeSSHSign; sshCA is disabled for gcp provisioner '%s'", p.GetName())
406+
func (p *GCP) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) {
407+
certType, hasCertType := CertTypeFromContext(ctx)
408+
if !hasCertType {
409+
certType = SSHHostCert
410+
}
411+
412+
err := p.isUnauthorizedToIssueSSHCert(certType)
413+
if err != nil {
414+
return nil, err
393415
}
416+
394417
claims, err := p.authorizeToken(token)
395418
if err != nil {
396419
return nil, errs.Wrap(http.StatusInternalServerError, err, "gcp.AuthorizeSSHSign")
397420
}
398421

399-
ce := claims.Google.ComputeEngine
400-
signOptions := []SignOption{}
422+
var principals []string
423+
var keyID string
424+
var defaults SignSSHOptions
425+
var ct sshutil.CertType
426+
var template string
401427

402-
// Enforce host certificate.
403-
defaults := SignSSHOptions{
404-
CertType: SSHHostCert,
428+
switch certType {
429+
case SSHHostCert:
430+
defaults, keyID, principals, ct, template = p.genHostOptions(ctx, claims)
431+
case SSHUserCert:
432+
defaults, keyID, principals, ct, template = p.genUserOptions(ctx, claims)
433+
default:
434+
return nil, errs.Unauthorized("gcp.AuthorizeSSHSign; invalid requested certType")
405435
}
406436

407-
// Validated principals.
408-
principals := []string{
409-
fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
410-
fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
411-
}
437+
signOptions := []SignOption{}
412438

413-
// Only enforce known principals if disable custom sans is true.
414-
if p.DisableCustomSANs {
439+
// Only enforce known principals if disable custom sans is true, or it is a user cert request
440+
if p.DisableCustomSANs || certType == SSHUserCert {
415441
defaults.Principals = principals
416442
} else {
417443
// Check that at least one principal is sent in the request.
@@ -421,12 +447,12 @@ func (p *GCP) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, e
421447
}
422448

423449
// Certificate templates.
424-
data := sshutil.CreateTemplateData(sshutil.HostCert, ce.InstanceName, principals)
450+
data := sshutil.CreateTemplateData(ct, keyID, principals)
425451
if v, err := unsafeParseSigned(token); err == nil {
426452
data.SetToken(v)
427453
}
428454

429-
templateOptions, err := CustomSSHTemplateOptions(p.Options, data, sshutil.DefaultIIDTemplate)
455+
templateOptions, err := CustomSSHTemplateOptions(p.Options, data, template)
430456
if err != nil {
431457
return nil, errs.Wrap(http.StatusInternalServerError, err, "gcp.AuthorizeSSHSign")
432458
}
@@ -445,12 +471,54 @@ func (p *GCP) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, e
445471
// Require all the fields in the SSH certificate
446472
&sshCertDefaultValidator{},
447473
// Ensure that all principal names are allowed
448-
newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), nil),
474+
newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), p.ctl.getPolicy().getSSHUser()),
449475
// Call webhooks
450476
p.ctl.newWebhookController(
451477
data,
452478
linkedca.Webhook_SSH,
453-
webhook.WithAuthorizationPrincipal(ce.InstanceID),
479+
webhook.WithAuthorizationPrincipal(keyID),
454480
),
455481
), nil
456482
}
483+
484+
func (p *GCP) genHostOptions(_ context.Context, claims *gcpPayload) (SignSSHOptions, string, []string, sshutil.CertType, string) {
485+
ce := claims.Google.ComputeEngine
486+
keyID := ce.InstanceName
487+
488+
principals := []string{
489+
fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
490+
fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
491+
}
492+
493+
return SignSSHOptions{CertType: SSHHostCert}, keyID, principals, sshutil.HostCert, sshutil.DefaultIIDTemplate
494+
}
495+
496+
func FormatServiceAccountUsername(serviceAccountId string) string {
497+
return fmt.Sprintf("sa_%v", serviceAccountId)
498+
}
499+
500+
func (p *GCP) genUserOptions(_ context.Context, claims *gcpPayload) (SignSSHOptions, string, []string, sshutil.CertType, string) {
501+
keyID := claims.Email
502+
principals := []string{
503+
FormatServiceAccountUsername(claims.Subject),
504+
claims.Email,
505+
}
506+
507+
return SignSSHOptions{CertType: SSHUserCert}, keyID, principals, sshutil.UserCert, sshutil.DefaultTemplate
508+
}
509+
510+
func (p *GCP) isUnauthorizedToIssueSSHCert(certType string) error {
511+
if !p.ctl.Claimer.IsSSHCAEnabled() {
512+
return errs.Unauthorized("gcp.AuthorizeSSHSign; sshCA is disabled for gcp provisioner '%s'", p.GetName())
513+
}
514+
515+
if certType == SSHHostCert && *p.DisableSSHCAHost {
516+
return errs.Unauthorized("gcp.AuthorizeSSHSign; sshCA for Hosts is disabled for gcp provisioner '%s'", p.GetName())
517+
}
518+
519+
if certType == SSHUserCert && *p.DisableSSHCAUser {
520+
return errs.Unauthorized("gcp.AuthorizeSSHSign; sshCA for Users is disabled for gcp provisioner '%s'", p.GetName())
521+
}
522+
523+
return nil
524+
}

authority/provisioner/gcp_test.go

+32-3
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ func TestGCP_Init(t *testing.T) {
186186
args args
187187
wantErr bool
188188
}{
189+
{"ok", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL}, false},
189190
{"ok", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL}, false},
190191
{"ok", fields{"GCP", "name", []string{"service-account"}, zero, nil}, args{config, srv.URL}, false},
191192
{"ok", fields{"GCP", "name", []string{"service-account"}, Duration{Duration: 1 * time.Minute}, nil}, args{config, srv.URL}, false},
@@ -211,6 +212,14 @@ func TestGCP_Init(t *testing.T) {
211212
if err := p.Init(tt.args.config); (err != nil) != tt.wantErr {
212213
t.Errorf("GCP.Init() error = %v, wantErr %v", err, tt.wantErr)
213214
}
215+
216+
if *p.DisableSSHCAUser != true {
217+
t.Errorf("By default DisableSSHCAUser should be true")
218+
}
219+
220+
if *p.DisableSSHCAHost != false {
221+
t.Errorf("By default DisableSSHCAHost should be false")
222+
}
214223
})
215224
}
216225
}
@@ -592,6 +601,9 @@ func TestGCP_AuthorizeSSHSign(t *testing.T) {
592601
p1, err := generateGCP()
593602
assert.FatalError(t, err)
594603
p1.DisableCustomSANs = true
604+
// enable ssh user CA
605+
disableSSCAUser := false
606+
p1.DisableSSHCAUser = &disableSSCAUser
595607

596608
p2, err := generateGCP()
597609
assert.FatalError(t, err)
@@ -605,6 +617,12 @@ func TestGCP_AuthorizeSSHSign(t *testing.T) {
605617
p3.ctl.Claimer, err = NewClaimer(p3.Claims, globalProvisionerClaims)
606618
assert.FatalError(t, err)
607619

620+
p4, err := generateGCP()
621+
assert.FatalError(t, err)
622+
// disable ssh host CA
623+
disableSSCAHost := true
624+
p4.DisableSSHCAHost = &disableSSCAHost
625+
608626
t1, err := generateGCPToken(p1.ServiceAccounts[0],
609627
"https://accounts.google.com", p1.GetID(),
610628
"instance-id", "instance-name", "project-id", "zone",
@@ -647,6 +665,10 @@ func TestGCP_AuthorizeSSHSign(t *testing.T) {
647665
CertType: "host", Principals: []string{"foo.bar", "bar.foo"},
648666
ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(hostDuration)),
649667
}
668+
expectedUserOptions := &SignSSHOptions{
669+
CertType: "user", Principals: []string{FormatServiceAccountUsername(p1.ServiceAccounts[0]), "foo@developer.gserviceaccount.com"},
670+
ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(p1.ctl.Claimer.DefaultUserSSHCertDuration())),
671+
}
650672

651673
type args struct {
652674
token string
@@ -664,22 +686,29 @@ func TestGCP_AuthorizeSSHSign(t *testing.T) {
664686
}{
665687
{"ok", p1, args{t1, SignSSHOptions{}, pub}, expectedHostOptions, http.StatusOK, false, false},
666688
{"ok-rsa2048", p1, args{t1, SignSSHOptions{}, rsa2048.Public()}, expectedHostOptions, http.StatusOK, false, false},
667-
{"ok-type", p1, args{t1, SignSSHOptions{CertType: "host"}, pub}, expectedHostOptions, http.StatusOK, false, false},
689+
{"ok-type-host", p1, args{t1, SignSSHOptions{CertType: "host"}, pub}, expectedHostOptions, http.StatusOK, false, false},
690+
{"ok-type-user", p1, args{t1, SignSSHOptions{CertType: "user"}, pub}, expectedUserOptions, http.StatusOK, false, false},
668691
{"ok-principals", p1, args{t1, SignSSHOptions{Principals: []string{"instance-name.c.project-id.internal", "instance-name.zone.c.project-id.internal"}}, pub}, expectedHostOptions, http.StatusOK, false, false},
669692
{"ok-principal1", p1, args{t1, SignSSHOptions{Principals: []string{"instance-name.c.project-id.internal"}}, pub}, expectedHostOptionsPrincipal1, http.StatusOK, false, false},
670693
{"ok-principal2", p1, args{t1, SignSSHOptions{Principals: []string{"instance-name.zone.c.project-id.internal"}}, pub}, expectedHostOptionsPrincipal2, http.StatusOK, false, false},
671694
{"ok-options", p1, args{t1, SignSSHOptions{CertType: "host", Principals: []string{"instance-name.c.project-id.internal", "instance-name.zone.c.project-id.internal"}}, pub}, expectedHostOptions, http.StatusOK, false, false},
672695
{"ok-custom", p2, args{t2, SignSSHOptions{Principals: []string{"foo.bar", "bar.foo"}}, pub}, expectedCustomOptions, http.StatusOK, false, false},
673696
{"fail-rsa1024", p1, args{t1, SignSSHOptions{}, rsa1024.Public()}, expectedHostOptions, http.StatusOK, false, true},
674-
{"fail-type", p1, args{t1, SignSSHOptions{CertType: "user"}, pub}, nil, http.StatusOK, false, true},
675697
{"fail-principal", p1, args{t1, SignSSHOptions{Principals: []string{"smallstep.com"}}, pub}, nil, http.StatusOK, false, true},
676698
{"fail-extra-principal", p1, args{t1, SignSSHOptions{Principals: []string{"instance-name.c.project-id.internal", "instance-name.zone.c.project-id.internal", "smallstep.com"}}, pub}, nil, http.StatusOK, false, true},
677699
{"fail-sshCA-disabled", p3, args{"foo", SignSSHOptions{}, pub}, expectedHostOptions, http.StatusUnauthorized, true, false},
700+
{"fail-type-host", p4, args{"foo", SignSSHOptions{CertType: "host"}, pub}, nil, http.StatusUnauthorized, true, false},
701+
{"fail-type-user", p4, args{"foo", SignSSHOptions{CertType: "host"}, pub}, nil, http.StatusUnauthorized, true, false},
678702
{"fail-invalid-token", p1, args{"foo", SignSSHOptions{}, pub}, expectedHostOptions, http.StatusUnauthorized, true, false},
679703
}
680704
for _, tt := range tests {
681705
t.Run(tt.name, func(t *testing.T) {
682-
got, err := tt.gcp.AuthorizeSSHSign(context.Background(), tt.args.token)
706+
ctx := context.Background()
707+
if tt.args.sshOpts.CertType == SSHUserCert {
708+
ctx = NewContextWithCertType(ctx, SSHUserCert)
709+
}
710+
711+
got, err := tt.gcp.AuthorizeSSHSign(ctx, tt.args.token)
683712
if (err != nil) != tt.wantErr {
684713
t.Errorf("GCP.AuthorizeSSHSign() error = %v, wantErr %v", err, tt.wantErr)
685714
return

authority/provisioner/method.go

+14
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,17 @@ func TokenFromContext(ctx context.Context) (string, bool) {
7878
token, ok := ctx.Value(tokenKey{}).(string)
7979
return token, ok
8080
}
81+
82+
// The key to save the certTypeKey in the context.
83+
type certTypeKey struct{}
84+
85+
// NewContextWithCertType creates a new context with the given CertType.
86+
func NewContextWithCertType(ctx context.Context, certType string) context.Context {
87+
return context.WithValue(ctx, certTypeKey{}, certType)
88+
}
89+
90+
// CertTypeFromContext returns the certType stored in the given context.
91+
func CertTypeFromContext(ctx context.Context) (string, bool) {
92+
certType, ok := ctx.Value(certTypeKey{}).(string)
93+
return certType, ok
94+
}

authority/provisioner/utils_test.go

+7-5
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,13 @@ func generateGCP() (*GCP, error) {
363363
return nil, err
364364
}
365365
p := &GCP{
366-
Type: "GCP",
367-
Name: name,
368-
ServiceAccounts: []string{serviceAccount},
369-
Claims: &globalProvisionerClaims,
370-
config: newGCPConfig(),
366+
Type: "GCP",
367+
Name: name,
368+
ServiceAccounts: []string{serviceAccount},
369+
Claims: &globalProvisionerClaims,
370+
DisableSSHCAHost: &DefaultDisableSSHCAHost,
371+
DisableSSHCAUser: &DefaultDisableSSHCAUser,
372+
config: newGCPConfig(),
371373
keyStore: &keyStore{
372374
keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}},
373375
expiry: time.Now().Add(24 * time.Hour),

0 commit comments

Comments
 (0)