/* * * Copyright 2020 gRPC authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ // Package pemfile provides a file watching certificate provider plugin // implementation which works for files with PEM contents. // // # Experimental // // Notice: All APIs in this package are experimental and may be removed in a // later release. package pemfile import ( "bytes" "context" "crypto/tls" "crypto/x509" "errors" "fmt" "os" "path/filepath" "time" "google.golang.org/grpc/credentials/tls/certprovider" "google.golang.org/grpc/grpclog" ) const defaultCertRefreshDuration = 1 * time.Hour var ( // For overriding from unit tests. newDistributor = func() distributor { return certprovider.NewDistributor() } logger = grpclog.Component("pemfile") ) // Options configures a certificate provider plugin that watches a specified set // of files that contain certificates and keys in PEM format. type Options struct { // CertFile is the file that holds the identity certificate. // Optional. If this is set, KeyFile must also be set. CertFile string // KeyFile is the file that holds identity private key. // Optional. If this is set, CertFile must also be set. KeyFile string // RootFile is the file that holds trusted root certificate(s). // Optional. RootFile string // RefreshDuration is the amount of time the plugin waits before checking // for updates in the specified files. // Optional. If not set, a default value (1 hour) will be used. RefreshDuration time.Duration } func (o Options) canonical() []byte { return []byte(fmt.Sprintf("%s:%s:%s:%s", o.CertFile, o.KeyFile, o.RootFile, o.RefreshDuration)) } func (o Options) validate() error { if o.CertFile == "" && o.KeyFile == "" && o.RootFile == "" { return fmt.Errorf("pemfile: at least one credential file needs to be specified") } if keySpecified, certSpecified := o.KeyFile != "", o.CertFile != ""; keySpecified != certSpecified { return fmt.Errorf("pemfile: private key file and identity cert file should be both specified or not specified") } // C-core has a limitation that they cannot verify that a certificate file // matches a key file. So, the only way to get around this is to make sure // that both files are in the same directory and that they do an atomic // read. Even though Java/Go do not have this limitation, we want the // overall plugin behavior to be consistent across languages. if certDir, keyDir := filepath.Dir(o.CertFile), filepath.Dir(o.KeyFile); certDir != keyDir { return errors.New("pemfile: certificate and key file must be in the same directory") } return nil } // NewProvider returns a new certificate provider plugin that is configured to // watch the PEM files specified in the passed in options. func NewProvider(o Options) (certprovider.Provider, error) { if err := o.validate(); err != nil { return nil, err } return newProvider(o), nil } // newProvider is used to create a new certificate provider plugin after // validating the options, and hence does not return an error. func newProvider(o Options) certprovider.Provider { if o.RefreshDuration == 0 { o.RefreshDuration = defaultCertRefreshDuration } provider := &watcher{opts: o} if o.CertFile != "" && o.KeyFile != "" { provider.identityDistributor = newDistributor() } if o.RootFile != "" { provider.rootDistributor = newDistributor() } ctx, cancel := context.WithCancel(context.Background()) provider.cancel = cancel go provider.run(ctx) return provider } // watcher is a certificate provider plugin that implements the // certprovider.Provider interface. It watches a set of certificate and key // files and provides the most up-to-date key material for consumption by // credentials implementation. type watcher struct { identityDistributor distributor rootDistributor distributor opts Options certFileContents []byte keyFileContents []byte rootFileContents []byte cancel context.CancelFunc } // distributor wraps the methods on certprovider.Distributor which are used by // the plugin. This is very useful in tests which need to know exactly when the // plugin updates its key material. type distributor interface { KeyMaterial(ctx context.Context) (*certprovider.KeyMaterial, error) Set(km *certprovider.KeyMaterial, err error) Stop() } // updateIdentityDistributor checks if the cert/key files that the plugin is // watching have changed, and if so, reads the new contents and updates the // identityDistributor with the new key material. // // Skips updates when file reading or parsing fails. // TODO(easwars): Retry with limit (on the number of retries or the amount of // time) upon failures. func (w *watcher) updateIdentityDistributor() { if w.identityDistributor == nil { return } certFileContents, err := os.ReadFile(w.opts.CertFile) if err != nil { logger.Warningf("certFile (%s) read failed: %v", w.opts.CertFile, err) return } keyFileContents, err := os.ReadFile(w.opts.KeyFile) if err != nil { logger.Warningf("keyFile (%s) read failed: %v", w.opts.KeyFile, err) return } // If the file contents have not changed, skip updating the distributor. if bytes.Equal(w.certFileContents, certFileContents) && bytes.Equal(w.keyFileContents, keyFileContents) { return } cert, err := tls.X509KeyPair(certFileContents, keyFileContents) if err != nil { logger.Warningf("tls.X509KeyPair(%q, %q) failed: %v", certFileContents, keyFileContents, err) return } w.certFileContents = certFileContents w.keyFileContents = keyFileContents w.identityDistributor.Set(&certprovider.KeyMaterial{Certs: []tls.Certificate{cert}}, nil) } // updateRootDistributor checks if the root cert file that the plugin is // watching hs changed, and if so, updates the rootDistributor with the new key // material. // // Skips updates when root cert reading or parsing fails. // TODO(easwars): Retry with limit (on the number of retries or the amount of // time) upon failures. func (w *watcher) updateRootDistributor() { if w.rootDistributor == nil { return } rootFileContents, err := os.ReadFile(w.opts.RootFile) if err != nil { logger.Warningf("rootFile (%s) read failed: %v", w.opts.RootFile, err) return } trustPool := x509.NewCertPool() if !trustPool.AppendCertsFromPEM(rootFileContents) { logger.Warning("failed to parse root certificate") return } // If the file contents have not changed, skip updating the distributor. if bytes.Equal(w.rootFileContents, rootFileContents) { return } w.rootFileContents = rootFileContents w.rootDistributor.Set(&certprovider.KeyMaterial{Roots: trustPool}, nil) } // run is a long running goroutine which watches the configured files for // changes, and pushes new key material into the appropriate distributors which // is returned from calls to KeyMaterial(). func (w *watcher) run(ctx context.Context) { ticker := time.NewTicker(w.opts.RefreshDuration) for { w.updateIdentityDistributor() w.updateRootDistributor() select { case <-ctx.Done(): ticker.Stop() if w.identityDistributor != nil { w.identityDistributor.Stop() } if w.rootDistributor != nil { w.rootDistributor.Stop() } return case <-ticker.C: } } } // KeyMaterial returns the key material sourced by the watcher. // Callers are expected to use the returned value as read-only. func (w *watcher) KeyMaterial(ctx context.Context) (*certprovider.KeyMaterial, error) { km := &certprovider.KeyMaterial{} if w.identityDistributor != nil { identityKM, err := w.identityDistributor.KeyMaterial(ctx) if err != nil { return nil, err } km.Certs = identityKM.Certs } if w.rootDistributor != nil { rootKM, err := w.rootDistributor.KeyMaterial(ctx) if err != nil { return nil, err } km.Roots = rootKM.Roots } return km, nil } // Close cleans up resources allocated by the watcher. func (w *watcher) Close() { w.cancel() }