diff --git a/command/operator_diagnose.go b/command/operator_diagnose.go index 227765c852..68d6271a2e 100644 --- a/command/operator_diagnose.go +++ b/command/operator_diagnose.go @@ -255,7 +255,7 @@ func (c *OperatorDiagnoseCommand) offlineDiagnostics(ctx context.Context) error if config.Storage != nil && config.Storage.Type == storageTypeConsul { diagnose.Test(ctx, "test-storage-tls-consul", func(ctx context.Context) error { - err = physconsul.SetupSecureTLS(api.DefaultConfig(), config.Storage.Config, server.logger, true) + err = physconsul.SetupSecureTLS(ctx, api.DefaultConfig(), config.Storage.Config, server.logger, true) if err != nil { return err } @@ -323,7 +323,7 @@ func (c *OperatorDiagnoseCommand) offlineDiagnostics(ctx context.Context) error diagnose.Test(ctx, "test-serviceregistration-tls-consul", func(ctx context.Context) error { // SetupSecureTLS for service discovery uses the same cert and key to set up physical // storage. See the consul package in physical for details. - err = srconsul.SetupSecureTLS(api.DefaultConfig(), srConfig, server.logger, true) + err = srconsul.SetupSecureTLS(ctx, api.DefaultConfig(), srConfig, server.logger, true) if err != nil { return err } @@ -424,7 +424,7 @@ SEALFAIL: }) if config.HAStorage != nil && config.HAStorage.Type == storageTypeConsul { diagnose.Test(ctx, "test-ha-storage-tls-consul", func(ctx context.Context) error { - err = physconsul.SetupSecureTLS(api.DefaultConfig(), config.HAStorage.Config, server.logger, true) + err = physconsul.SetupSecureTLS(ctx, api.DefaultConfig(), config.HAStorage.Config, server.logger, true) if err != nil { return err } @@ -493,36 +493,26 @@ SEALFAIL: defer c.cleanupGuard.Do(listenerCloseFunc) - diagnose.Test(ctx, "check-listener-tls", func(ctx context.Context) error { - sanitizedListeners := make([]listenerutil.Listener, 0, len(config.Listeners)) - for _, ln := range lns { - if ln.Config.TLSDisable { - diagnose.Warn(ctx, "TLS is disabled in a Listener config stanza.") - continue - } - if ln.Config.TLSDisableClientCerts { - diagnose.Warn(ctx, "TLS for a listener is turned on without requiring client certs.") - } - - // Check ciphersuite and load ca/cert/key files - // TODO: TLSConfig returns a reloadFunc and a TLSConfig. We can use this to - // perform an active probe. - _, _, err := listenerutil.TLSConfig(ln.Config, make(map[string]string), c.UI) - if err != nil { - return err - } - - sanitizedListeners = append(sanitizedListeners, listenerutil.Listener{ - Listener: ln.Listener, - Config: ln.Config, - }) + listenerTLSContext, listenerTLSSpan := diagnose.StartSpan(ctx, "check-listener-tls") + sanitizedListeners := make([]listenerutil.Listener, 0, len(config.Listeners)) + for _, ln := range lns { + if ln.Config.TLSDisable { + diagnose.Warn(listenerTLSContext, "TLS is disabled in a Listener config stanza.") + continue } - err = diagnose.ListenerChecks(sanitizedListeners) - if err != nil { - return err + if ln.Config.TLSDisableClientCerts { + diagnose.Warn(listenerTLSContext, "TLS for a listener is turned on without requiring client certs.") } - return nil - }) + + sanitizedListeners = append(sanitizedListeners, listenerutil.Listener{ + Listener: ln.Listener, + Config: ln.Config, + }) + } + diagnose.ListenerChecks(listenerTLSContext, sanitizedListeners) + + listenerTLSSpan.End() + return nil }) diff --git a/command/operator_diagnose_test.go b/command/operator_diagnose_test.go index 565699e31d..74fdce5fad 100644 --- a/command/operator_diagnose_test.go +++ b/command/operator_diagnose_test.go @@ -226,7 +226,10 @@ func TestOperatorDiagnoseCommand_Run(t *testing.T) { { Name: "test-storage-tls-consul", Status: diagnose.ErrorStatus, - Message: "expired", + Message: "certificate has expired or is not yet valid", + Warnings: []string{ + "expired or near expiry", + }, }, { Name: "test-consul-direct-access-storage", @@ -281,7 +284,10 @@ func TestOperatorDiagnoseCommand_Run(t *testing.T) { { Name: "test-ha-storage-tls-consul", Status: diagnose.ErrorStatus, - Message: "x509: certificate has expired or is not yet valid", + Message: "certificate has expired or is not yet valid", + Warnings: []string{ + "expired or near expiry", + }, }, }, }, @@ -304,7 +310,10 @@ func TestOperatorDiagnoseCommand_Run(t *testing.T) { { Name: "test-serviceregistration-tls-consul", Status: diagnose.ErrorStatus, - Message: "failed to verify certificate: x509: certificate has expired or is not yet valid", + Message: "certificate has expired or is not yet valid", + Warnings: []string{ + "expired or near expiry", + }, }, { Name: "test-consul-direct-access-service-discovery", diff --git a/physical/consul/consul.go b/physical/consul/consul.go index 814a341117..8bcb92ddcc 100644 --- a/physical/consul/consul.go +++ b/physical/consul/consul.go @@ -129,7 +129,7 @@ func NewConsulBackend(conf map[string]string, logger log.Logger) (physical.Backe // Set MaxIdleConnsPerHost to the number of processes used in expiration.Restore consulConf.Transport.MaxIdleConnsPerHost = consts.ExpirationRestoreWorkerCount - SetupSecureTLS(consulConf, conf, logger, false) + SetupSecureTLS(context.Background(), consulConf, conf, logger, false) consulConf.HttpClient = &http.Client{Transport: consulConf.Transport} client, err := api.NewClient(consulConf) @@ -151,7 +151,7 @@ func NewConsulBackend(conf map[string]string, logger log.Logger) (physical.Backe return c, nil } -func SetupSecureTLS(consulConf *api.Config, conf map[string]string, logger log.Logger, isDiagnose bool) error { +func SetupSecureTLS(ctx context.Context, consulConf *api.Config, conf map[string]string, logger log.Logger, isDiagnose bool) error { if addr, ok := conf["address"]; ok { consulConf.Address = addr if logger.IsDebug() { @@ -189,13 +189,16 @@ func SetupSecureTLS(consulConf *api.Config, conf map[string]string, logger log.L certPath, okCert := conf["tls_cert_file"] keyPath, okKey := conf["tls_key_file"] if okCert && okKey { - err := diagnose.TLSFileChecks(certPath, keyPath) + warnings, err := diagnose.TLSFileChecks(certPath, keyPath) + for _, warning := range warnings { + diagnose.Warn(ctx, warning) + } if err != nil { return err } - } else { - return fmt.Errorf("key or cert path: %s, %s, cannot be loaded from consul config file", certPath, keyPath) + return nil } + return fmt.Errorf("key or cert path: %s, %s, cannot be loaded from consul config file", certPath, keyPath) } // Use the parsed Address instead of the raw conf['address'] diff --git a/serviceregistration/consul/consul_service_registration.go b/serviceregistration/consul/consul_service_registration.go index 89440581be..cf126bebf4 100644 --- a/serviceregistration/consul/consul_service_registration.go +++ b/serviceregistration/consul/consul_service_registration.go @@ -1,6 +1,7 @@ package consul import ( + "context" "errors" "fmt" "math/rand" @@ -146,7 +147,7 @@ func NewServiceRegistration(conf map[string]string, logger log.Logger, state sr. // Set MaxIdleConnsPerHost to the number of processes used in expiration.Restore consulConf.Transport.MaxIdleConnsPerHost = consts.ExpirationRestoreWorkerCount - SetupSecureTLS(consulConf, conf, logger, false) + SetupSecureTLS(context.Background(), consulConf, conf, logger, false) consulConf.HttpClient = &http.Client{Transport: consulConf.Transport} client, err := api.NewClient(consulConf) @@ -178,7 +179,7 @@ func NewServiceRegistration(conf map[string]string, logger log.Logger, state sr. return c, nil } -func SetupSecureTLS(consulConf *api.Config, conf map[string]string, logger log.Logger, isDiagnose bool) error { +func SetupSecureTLS(ctx context.Context, consulConf *api.Config, conf map[string]string, logger log.Logger, isDiagnose bool) error { if addr, ok := conf["address"]; ok { consulConf.Address = addr if logger.IsDebug() { @@ -216,13 +217,16 @@ func SetupSecureTLS(consulConf *api.Config, conf map[string]string, logger log.L certPath, okCert := conf["tls_cert_file"] keyPath, okKey := conf["tls_key_file"] if okCert && okKey { - err := diagnose.TLSFileChecks(certPath, keyPath) + warnings, err := diagnose.TLSFileChecks(certPath, keyPath) + for _, warning := range warnings { + diagnose.Warn(ctx, warning) + } if err != nil { return err } - } else { - return fmt.Errorf("key or cert path: %s, %s, cannot be loaded from consul config file", certPath, keyPath) + return nil } + return fmt.Errorf("key or cert path: %s, %s, cannot be loaded from consul config file", certPath, keyPath) } // Use the parsed Address instead of the raw conf['address'] diff --git a/vault/diagnose/tls_verification.go b/vault/diagnose/tls_verification.go index 244be03cf6..ad4f41bf68 100644 --- a/vault/diagnose/tls_verification.go +++ b/vault/diagnose/tls_verification.go @@ -2,11 +2,13 @@ package diagnose import ( "bytes" + "context" "crypto/tls" "crypto/x509" "encoding/pem" "fmt" "io/ioutil" + "time" "github.com/hashicorp/vault/internalshared/listenerutil" "github.com/hashicorp/vault/sdk/helper/tlsutil" @@ -15,9 +17,18 @@ import ( const minVersionError = "'tls_min_version' value %q not supported, please specify one of [tls10,tls11,tls12,tls13]" const maxVersionError = "'tls_max_version' value %q not supported, please specify one of [tls10,tls11,tls12,tls13]" -func ListenerChecks(listeners []listenerutil.Listener) error { +// ListenerChecks diagnoses warnings and the first encountered error for the listener +// configuration stanzas. +func ListenerChecks(ctx context.Context, listeners []listenerutil.Listener) ([]string, []error) { + + // These aggregated warnings and errors are returned purely for testing purposes. + // The errors and warnings will report in this function itself. + var listenerWarnings []string + var listenerErrors []error + for _, listener := range listeners { l := listener.Config + listenerID := l.Address // Perform the TLS version check for listeners. if l.TLSMinVersion == "" { @@ -28,64 +39,119 @@ func ListenerChecks(listeners []listenerutil.Listener) error { } _, ok := tlsutil.TLSLookup[l.TLSMinVersion] if !ok { - return fmt.Errorf(minVersionError, l.TLSMinVersion) + err := fmt.Errorf("listener at address: %s has error %s: ", listenerID, fmt.Sprintf(minVersionError, l.TLSMinVersion)) + listenerErrors = append(listenerErrors, err) + Error(ctx, err) } _, ok = tlsutil.TLSLookup[l.TLSMaxVersion] if !ok { - return fmt.Errorf(maxVersionError, l.TLSMaxVersion) + err := fmt.Errorf("listener at address: %s has error %s: ", listenerID, fmt.Sprintf(maxVersionError, l.TLSMaxVersion)) + listenerErrors = append(listenerErrors, err) + Error(ctx, err) } // Perform checks on the TLS Cryptographic Information. - if err := TLSFileChecks(l.TLSCertFile, l.TLSKeyFile); err != nil { - return err + warnings, err := TLSFileChecks(l.TLSCertFile, l.TLSKeyFile) + for _, warning := range warnings { + warning = listenerID + ": " + warning + listenerWarnings = append(listenerWarnings, warning) + Warn(ctx, warning) } + if err != nil { + errMsg := listenerID + ": " + err.Error() + listenerErrors = append(listenerErrors, fmt.Errorf(errMsg)) + Error(ctx, fmt.Errorf(errMsg)) + } + + // TODO: Use listenerutil.TLSConfig to warn on incorrect protocol specified + // Alternatively, use tlsutil.SetupTLSConfig. } - return nil + return listenerWarnings, listenerErrors } -// TLSFileChecks contains manual error checks against the TLS configuration -func TLSFileChecks(certFilePath, keyFilePath string) error { +// TLSFileChecks returns an error and warnings after checking TLS information +func TLSFileChecks(certpath, keypath string) ([]string, error) { + // Parse TLS Certs from the certpath + leafCerts, interCerts, rootCerts, err := ParseTLSInformation(certpath) + if err != nil { + return nil, err + } + + // Check for TLS Warnings + warnings, err := TLSFileWarningChecks(leafCerts, interCerts, rootCerts) + if err != nil { + return warnings, err + } + + // Check for TLS Errors + if err = TLSErrorChecks(leafCerts, interCerts, rootCerts); err != nil { + return warnings, err + } + + // Utilize the native TLS Loading mechanism to ensure we have missed no errors + _, err = tls.LoadX509KeyPair(certpath, keypath) + return warnings, err +} + +// ParseTLSInformation parses certficate information and returns it from a cert path. +func ParseTLSInformation(certFilePath string) ([]*x509.Certificate, []*x509.Certificate, []*x509.Certificate, error) { + leafCerts := []*x509.Certificate{} + interCerts := []*x509.Certificate{} + rootCerts := []*x509.Certificate{} data, err := ioutil.ReadFile(certFilePath) if err != nil { - return fmt.Errorf("failed to read tls_client_ca_file: %w", err) + return leafCerts, interCerts, rootCerts, fmt.Errorf("failed to read certificate file: %w", err) } certBlocks := []*pem.Block{} - leafCerts := []*x509.Certificate{} - rootPool := x509.NewCertPool() - interPool := x509.NewCertPool() rst := []byte(data) for len(rst) != 0 { block, rest := pem.Decode(rst) if block == nil { - return fmt.Errorf("could not decode cert") + return leafCerts, interCerts, rootCerts, fmt.Errorf("could not decode cert") } certBlocks = append(certBlocks, block) rst = rest } if len(certBlocks) == 0 { - return fmt.Errorf("no certificates found in cert file") + return leafCerts, interCerts, rootCerts, fmt.Errorf("no certificates found in cert file") } for _, certBlock := range certBlocks { cert, err := x509.ParseCertificate(certBlock.Bytes) if err != nil { - return fmt.Errorf("A pem block does not parse to a certificate: %w", err) + return leafCerts, interCerts, rootCerts, fmt.Errorf("A pem block does not parse to a certificate: %w", err) } // Detect if the certificate is a root, leaf, or intermediate if cert.IsCA && bytes.Equal(cert.RawIssuer, cert.RawSubject) { // It's a root - rootPool.AddCert(cert) + rootCerts = append(rootCerts, cert) } else if cert.IsCA { // It's not a root but it's a CA, so it's an inter - interPool.AddCert(cert) + interCerts = append(interCerts, cert) } else { // It's gotta be a leaf leafCerts = append(leafCerts, cert) } } + return leafCerts, interCerts, rootCerts, nil +} + +// TLSErrorChecks contains manual error checks against the TLS configuration +func TLSErrorChecks(leafCerts, interCerts, rootCerts []*x509.Certificate) error { + // First, create root pools and interPools from the root and inter certs lists + + rootPool := x509.NewCertPool() + interPool := x509.NewCertPool() + + for _, root := range rootCerts { + rootPool.AddCert(root) + } + for _, inter := range interCerts { + interPool.AddCert(inter) + } // Make sure there's only one leaf. If there are multiple, it's a bad pem file. if len(leafCerts) != 1 { @@ -102,23 +168,47 @@ func TLSFileChecks(certFilePath, keyFilePath string) error { // Verify checks that certificate isn't expired, is of correct usage type, and has an appropriate // chain. - _, err = leafCerts[0].Verify(x509.VerifyOptions{ + _, err := leafCerts[0].Verify(x509.VerifyOptions{ Roots: rootPool, Intermediates: interPool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, }) if err != nil { - return fmt.Errorf("failed to verify certificate: %w", err) - } - - // After verify passes, we need to check the values on the certificate itself. - // This is a separate check beyond the certificate expiry and chain checks. - - _, err = tls.LoadX509KeyPair(certFilePath, keyFilePath) - if err != nil { - return err + return fmt.Errorf("failed to verify primary provided leaf certificate: %w", err) } return nil } + +// TLSFileWarningChecks returns warnings based on the leaf certificates, intermediate certificates, +// and root certificates provided. +func TLSFileWarningChecks(leafCerts, interCerts, rootCerts []*x509.Certificate) ([]string, error) { + var warnings []string + for _, c := range leafCerts { + if NearExpiration(c) { + warnings = append(warnings, fmt.Sprintf("leaf certificate %d is expired or near expiry", c.SerialNumber)) + } + } + for _, c := range interCerts { + if NearExpiration(c) { + warnings = append(warnings, fmt.Sprintf("intermediate certificate %d is expired or near expiry", c.SerialNumber)) + } + } + for _, c := range rootCerts { + if NearExpiration(c) { + warnings = append(warnings, fmt.Sprintf("root certificate %d is expired or near expiry", c.SerialNumber)) + } + } + + return warnings, nil +} + +// NearExpiration returns a true if a certficate will expire in a week and false otherwise +func NearExpiration(c *x509.Certificate) bool { + oneWeekFromNow := time.Now().Add(7 * 24 * time.Hour) + if oneWeekFromNow.After(c.NotAfter) { + return true + } + return false +} diff --git a/vault/diagnose/tls_verification_test.go b/vault/diagnose/tls_verification_test.go index 48f349a316..a10b4d8057 100644 --- a/vault/diagnose/tls_verification_test.go +++ b/vault/diagnose/tls_verification_test.go @@ -1,6 +1,7 @@ package diagnose import ( + "context" "fmt" "strings" "testing" @@ -26,9 +27,13 @@ func TestTLSValidCert(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err != nil { - t.Fatalf(err.Error()) + warnings, errs := ListenerChecks(context.Background(), listeners) + if errs != nil { + // The test failed -- we can just return one of the errors + t.Fatalf(errs[0].Error()) + } + if warnings != nil { + t.Fatalf("warnings returned from good listener") } } @@ -48,12 +53,15 @@ func TestTLSFakeCert(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil { t.Fatalf("TLS Config check on fake certificate should fail") } - if !strings.Contains(err.Error(), "could not decode cert") { - t.Fatalf("Bad error message: %s", err) + if len(errs) != 1 { + t.Fatalf("more than one error returned: %+v", errs) + } + if !strings.Contains(errs[0].Error(), "could not decode cert") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -76,12 +84,12 @@ func TestTLSTrailingData(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if !strings.Contains(err.Error(), "asn1: syntax error: trailing data") { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "asn1: syntax error: trailing data") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -102,12 +110,18 @@ func TestTLSExpiredCert(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + warnings, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if !strings.Contains(err.Error(), "certificate has expired or is not yet valid") { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "certificate has expired or is not yet valid") { + t.Fatalf("Bad error message: %s", errs[0]) + } + if warnings == nil || len(warnings) != 1 { + t.Fatalf("TLS Config check on fake certificate should warn") + } + if !strings.Contains(warnings[0], "expired or near expiry") { + t.Fatalf("Bad warning: %s", errs[0]) } } @@ -128,12 +142,12 @@ func TestTLSMismatchedCryptographicInfo(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if err.Error() != "tls: private key type does not match public key type" { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "tls: private key type does not match public key type") { + t.Fatalf("Bad error message: %s", errs[0]) } listeners = []listenerutil.Listener{ @@ -151,12 +165,12 @@ func TestTLSMismatchedCryptographicInfo(t *testing.T) { }, }, } - err = ListenerChecks(listeners) - if err == nil { + _, errs = ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if err.Error() != "tls: private key type does not match public key type" { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "tls: private key type does not match public key type") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -177,12 +191,12 @@ func TestTLSMultiKeys(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if !strings.Contains(err.Error(), "pem block does not parse to a certificate") { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "pem block does not parse to a certificate") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -202,12 +216,12 @@ func TestTLSMultiCerts(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if !strings.Contains(err.Error(), "found a certificate rather than a key in the PEM for the private key") { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "found a certificate rather than a key in the PEM for the private key") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -229,12 +243,12 @@ func TestTLSInvalidRoot(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if err.Error() != "failed to verify certificate: x509: certificate signed by unknown authority" { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), "certificate signed by unknown authority") { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -256,9 +270,9 @@ func TestTLSNoRoot(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err != nil { - t.Fatalf("Server certificate without root certificate is insecure, but still valid.") + _, errs := ListenerChecks(context.Background(), listeners) + if errs != nil { + t.Fatalf("server certificate without root certificate is insecure, but still valid") } } @@ -280,12 +294,12 @@ func TestTLSInvalidMinVersion(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if err.Error() != fmt.Errorf(minVersionError, "0").Error() { - t.Fatalf("Bad error message: %s", err) + if !strings.Contains(errs[0].Error(), fmt.Errorf(minVersionError, "0").Error()) { + t.Fatalf("Bad error message: %s", errs[0]) } } @@ -307,11 +321,11 @@ func TestTLSInvalidMaxVersion(t *testing.T) { }, }, } - err := ListenerChecks(listeners) - if err == nil { + _, errs := ListenerChecks(context.Background(), listeners) + if errs == nil || len(errs) != 1 { t.Fatalf("TLS Config check on fake certificate should fail") } - if err.Error() != fmt.Errorf(maxVersionError, "0").Error() { - t.Errorf("Bad error message: %w", err) + if !strings.Contains(errs[0].Error(), fmt.Errorf(maxVersionError, "0").Error()) { + t.Fatalf("Bad error message: %s", errs[0]) } }