ssh: don't skip the source-address critical option in CheckCert CertChecker.CheckCert ignored source-address on the assumption that serverAuthenticate would enforce it, but that only happens in the server-side user authentication path. Nothing enforced it in the host key path, so CheckHostKey accepted CA-signed host certificates carrying source-address regardless of the server's address, and the same applied to applications calling CheckCert directly. Drop the special case: every critical option must be listed in SupportedCriticalOptions, host and user certificates alike. Authenticate, the only path where serverAuthenticate does enforce source-address, checks against a copy of the CertChecker with that option appended. Fixes golang/go#80872 Change-Id: I3c3554b71ea2a4ce4b17696a9596b973b7d74b00 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/816840 Reviewed-by: David Chase <drchase@google.com> Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Filippo Valsorda <filippo@golang.org> Auto-Submit: Nicola Murino <nicola.murino@gmail.com>
diff --git a/ssh/certs.go b/ssh/certs.go index fa848f5..a3b802e 100644 --- a/ssh/certs.go +++ b/ssh/certs.go
@@ -10,6 +10,7 @@ "fmt" "io" "net" + "slices" "sort" "time" ) @@ -305,8 +306,11 @@ // minimally, the IsAuthority callback should be set. type CertChecker struct { // SupportedCriticalOptions lists the CriticalOptions that the - // server application layer understands. These are only used - // for user certificates. + // application layer understands. A certificate carrying a critical + // option that is not listed here is rejected. + // CertChecker.Authenticate additionally accepts the source-address + // option, which the server enforces on the Permissions that + // Authenticate returns. SupportedCriticalOptions []string // IsUserAuthority should return true if the key is recognized as an @@ -369,8 +373,9 @@ return c.CheckCert(hostname, cert) } -// Authenticate checks a user certificate. Authenticate can be used as -// a value for ServerConfig.PublicKeyCallback. +// Authenticate checks a user certificate. Authenticate can be used as a value +// for ServerConfig.PublicKeyCallback. The source-address critical option is +// allowed, as it will be enforced by the server. func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permissions, error) { cert, ok := pubKey.(*Certificate) if !ok { @@ -389,8 +394,11 @@ if !c.IsUserAuthority(cert.SignatureKey) { return nil, fmt.Errorf("ssh: certificate signed by unrecognized authority") } - - if err := c.CheckCert(conn.User(), cert); err != nil { + // The source-address critical option is enforced by serverAuthenticate, + // so it is supported regardless of SupportedCriticalOptions + cc := *c + cc.SupportedCriticalOptions = append(slices.Clip(cc.SupportedCriticalOptions), sourceAddressCriticalOption) + if err := cc.CheckCert(conn.User(), cert); err != nil { return nil, err } @@ -398,27 +406,15 @@ } // CheckCert checks CriticalOptions, ValidPrincipals, revocation, timestamp and -// the signature of the certificate. +// the signature of the certificate. Critical options that are not listed in +// SupportedCriticalOptions are rejected. func (c *CertChecker) CheckCert(principal string, cert *Certificate) error { if c.IsRevoked != nil && c.IsRevoked(cert) { return fmt.Errorf("ssh: certificate serial %d revoked", cert.Serial) } for opt := range cert.CriticalOptions { - // sourceAddressCriticalOption will be enforced by - // serverAuthenticate - if opt == sourceAddressCriticalOption { - continue - } - - found := false - for _, supp := range c.SupportedCriticalOptions { - if supp == opt { - found = true - break - } - } - if !found { + if !slices.Contains(c.SupportedCriticalOptions, opt) { return fmt.Errorf("ssh: unsupported critical option %q in certificate", opt) } }
diff --git a/ssh/certs_test.go b/ssh/certs_test.go index 8358b33..ad5ddfd 100644 --- a/ssh/certs_test.go +++ b/ssh/certs_test.go
@@ -385,6 +385,82 @@ } } +type testConnMeta struct { + ConnMetadata + user string +} + +func (c testConnMeta) User() string { return c.user } + +func TestCertCriticalOptions(t *testing.T) { + appended := make([]string, 2) + appended[0], appended[1] = "supported-option", "appended-option" + supported := appended[:1] + + checker := &CertChecker{ + SupportedCriticalOptions: supported, + IsHostAuthority: func(p PublicKey, addr string) bool { + return bytes.Equal(testPublicKeys["ecdsa"].Marshal(), p.Marshal()) + }, + IsUserAuthority: func(p PublicKey) bool { + return bytes.Equal(testPublicKeys["ecdsa"].Marshal(), p.Marshal()) + }, + } + + for _, test := range []struct { + name string + opts map[string]string + // succeed is the expected outcome of CheckHostKey, for a host + // certificate, and of CheckCert, for a user certificate. + succeed bool + // authSucceed is the expected outcome of Authenticate. + authSucceed bool + }{ + {name: "no critical options", opts: nil, succeed: true, authSucceed: true}, + {name: "source-address", opts: map[string]string{sourceAddressCriticalOption: "192.168.1.0/24"}, authSucceed: true}, + {name: "unknown option", opts: map[string]string{"unknown-option": ""}}, + {name: "supported option", opts: map[string]string{"supported-option": ""}, succeed: true, authSucceed: true}, + } { + hostCert := &Certificate{ + ValidPrincipals: []string{"hostname"}, + Key: testPublicKeys["rsa"], + ValidBefore: CertTimeInfinity, + CertType: HostCert, + Permissions: Permissions{CriticalOptions: test.opts}, + } + if err := hostCert.SignCert(rand.Reader, testSigners["ecdsa"]); err != nil { + t.Fatalf("SignCert: %v", err) + } + + if err := checker.CheckHostKey("hostname:22", nil, hostCert); (err == nil) != test.succeed { + t.Errorf("CheckHostKey(%s): got %v, want success=%v", test.name, err, test.succeed) + } + + userCert := &Certificate{ + ValidPrincipals: []string{"user"}, + Key: testPublicKeys["rsa"], + ValidBefore: CertTimeInfinity, + CertType: UserCert, + Permissions: Permissions{CriticalOptions: test.opts}, + } + if err := userCert.SignCert(rand.Reader, testSigners["ecdsa"]); err != nil { + t.Fatalf("SignCert: %v", err) + } + + if err := checker.CheckCert("user", userCert); (err == nil) != test.succeed { + t.Errorf("CheckCert(%s): got %v, want success=%v", test.name, err, test.succeed) + } + + if _, err := checker.Authenticate(testConnMeta{user: "user"}, userCert); (err == nil) != test.authSucceed { + t.Errorf("Authenticate(%s): got %v, want success=%v", test.name, err, test.authSucceed) + } + } + + if appended[1] != "appended-option" { + t.Errorf("Authenticate overwrote the caller's slice: got %q, want %q", appended[1], "appended-option") + } +} + type legacyRSASigner struct { Signer }