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}