secureconnect_cert.go

  1// Copyright 2023 Google LLC
  2//
  3// Licensed under the Apache License, Version 2.0 (the "License");
  4// you may not use this file except in compliance with the License.
  5// You may obtain a copy of the License at
  6//
  7//      http://www.apache.org/licenses/LICENSE-2.0
  8//
  9// Unless required by applicable law or agreed to in writing, software
 10// distributed under the License is distributed on an "AS IS" BASIS,
 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12// See the License for the specific language governing permissions and
 13// limitations under the License.
 14
 15package cert
 16
 17import (
 18	"crypto/tls"
 19	"crypto/x509"
 20	"encoding/json"
 21	"errors"
 22	"fmt"
 23	"os"
 24	"os/exec"
 25	"os/user"
 26	"path/filepath"
 27	"sync"
 28	"time"
 29)
 30
 31const (
 32	metadataPath = ".secureConnect"
 33	metadataFile = "context_aware_metadata.json"
 34)
 35
 36type secureConnectSource struct {
 37	metadata secureConnectMetadata
 38
 39	// Cache the cert to avoid executing helper command repeatedly.
 40	cachedCertMutex sync.Mutex
 41	cachedCert      *tls.Certificate
 42}
 43
 44type secureConnectMetadata struct {
 45	Cmd []string `json:"cert_provider_command"`
 46}
 47
 48// NewSecureConnectProvider creates a certificate source using
 49// the Secure Connect Helper and its associated metadata file.
 50//
 51// The configFilePath points to the location of the context aware metadata file.
 52// If configFilePath is empty, use the default context aware metadata location.
 53func NewSecureConnectProvider(configFilePath string) (Provider, error) {
 54	if configFilePath == "" {
 55		user, err := user.Current()
 56		if err != nil {
 57			// Error locating the default config means Secure Connect is not supported.
 58			return nil, errSourceUnavailable
 59		}
 60		configFilePath = filepath.Join(user.HomeDir, metadataPath, metadataFile)
 61	}
 62
 63	file, err := os.ReadFile(configFilePath)
 64	if err != nil {
 65		// Config file missing means Secure Connect is not supported.
 66		// There are non-os.ErrNotExist errors that may be returned.
 67		// (e.g. if the home directory is /dev/null, *nix systems will
 68		// return ENOTDIR instead of ENOENT)
 69		return nil, errSourceUnavailable
 70	}
 71
 72	var metadata secureConnectMetadata
 73	if err := json.Unmarshal(file, &metadata); err != nil {
 74		return nil, fmt.Errorf("cert: could not parse JSON in %q: %w", configFilePath, err)
 75	}
 76	if err := validateMetadata(metadata); err != nil {
 77		return nil, fmt.Errorf("cert: invalid config in %q: %w", configFilePath, err)
 78	}
 79	return (&secureConnectSource{
 80		metadata: metadata,
 81	}).getClientCertificate, nil
 82}
 83
 84func validateMetadata(metadata secureConnectMetadata) error {
 85	if len(metadata.Cmd) == 0 {
 86		return errors.New("empty cert_provider_command")
 87	}
 88	return nil
 89}
 90
 91func (s *secureConnectSource) getClientCertificate(info *tls.CertificateRequestInfo) (*tls.Certificate, error) {
 92	s.cachedCertMutex.Lock()
 93	defer s.cachedCertMutex.Unlock()
 94	if s.cachedCert != nil && !isCertificateExpired(s.cachedCert) {
 95		return s.cachedCert, nil
 96	}
 97	// Expand OS environment variables in the cert provider command such as "$HOME".
 98	for i := 0; i < len(s.metadata.Cmd); i++ {
 99		s.metadata.Cmd[i] = os.ExpandEnv(s.metadata.Cmd[i])
100	}
101	command := s.metadata.Cmd
102	data, err := exec.Command(command[0], command[1:]...).Output()
103	if err != nil {
104		return nil, err
105	}
106	cert, err := tls.X509KeyPair(data, data)
107	if err != nil {
108		return nil, err
109	}
110	s.cachedCert = &cert
111	return &cert, nil
112}
113
114// isCertificateExpired returns true if the given cert is expired or invalid.
115func isCertificateExpired(cert *tls.Certificate) bool {
116	if len(cert.Certificate) == 0 {
117		return true
118	}
119	parsed, err := x509.ParseCertificate(cert.Certificate[0])
120	if err != nil {
121		return true
122	}
123	return time.Now().After(parsed.NotAfter)
124}