secureconnect_cert.go raw
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
15 package cert
16
17 import (
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
31 const (
32 metadataPath = ".secureConnect"
33 metadataFile = "context_aware_metadata.json"
34 )
35
36 type 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
44 type 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.
53 func 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
84 func validateMetadata(metadata secureConnectMetadata) error {
85 if len(metadata.Cmd) == 0 {
86 return errors.New("empty cert_provider_command")
87 }
88 return nil
89 }
90
91 func (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.
115 func 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 }
125