Skip to content

Commit 3357daa

Browse files
committed
crypto/x509: speed up and deflake non-cgo Darwin root cert discovery
Piping into security verify-cert only worked on macOS Sierra, and was flaky for unknown reasons. Users reported that the number of trusted root certs stopped randomly jumping around once they switched to using verify-cert against files on disk instead of /dev/stdin. But even using "security verify-cert" on 150-200 certs took too long. It took 3.5 seconds on my machine. More than 4 goroutines hitting verify-cert didn't help much, and soon started to hurt instead. New strategy, from comments in the code: // 1. Run "security trust-settings-export" and "security // trust-settings-export -d" to discover the set of certs with some // user-tweaked trusy policy. We're too lazy to parse the XML (at // least at this stage of Go 1.8) to understand what the trust // policy actually is. We just learn that there is _some_ policy. // // 2. Run "security find-certificate" to dump the list of system root // CAs in PEM format. // // 3. For each dumped cert, conditionally verify it with "security // verify-cert" if that cert was in the set discovered in Step 1. // Without the Step 1 optimization, running "security verify-cert" // 150-200 times takes 3.5 seconds. With the optimization, the // whole process takes about 180 milliseconds with 1 untrusted root // CA. (Compared to 110ms in the cgo path) Fixes #18203 Change-Id: I4e9c11fa50d0273c615382e0d8f9fd03498d4cb4 Reviewed-on: https://go-review.googlesource.com/34389 Run-TryBot: Brad Fitzpatrick <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Ian Lance Taylor <[email protected]> Reviewed-by: Quentin Smith <[email protected]>
1 parent 4d02833 commit 3357daa

File tree

2 files changed

+180
-60
lines changed

2 files changed

+180
-60
lines changed

src/crypto/x509/root_darwin.go

Lines changed: 170 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,22 @@
77
package x509
88

99
import (
10+
"bufio"
1011
"bytes"
12+
"crypto/sha1"
1113
"encoding/pem"
1214
"fmt"
15+
"io"
1316
"io/ioutil"
1417
"os"
1518
"os/exec"
16-
"strconv"
19+
"path/filepath"
20+
"strings"
1721
"sync"
18-
"syscall"
1922
)
2023

24+
var debugExecDarwinRoots = strings.Contains(os.Getenv("GODEBUG"), "x509roots=1")
25+
2126
func (c *Certificate) systemVerify(opts *VerifyOptions) (chains [][]*Certificate, err error) {
2227
return nil, nil
2328
}
@@ -27,30 +32,85 @@ func (c *Certificate) systemVerify(opts *VerifyOptions) (chains [][]*Certificate
2732
// even if the tests are run with cgo enabled.
2833
// The linker will not include these unused functions in binaries built with cgo enabled.
2934

35+
// execSecurityRoots finds the macOS list of trusted root certificates
36+
// using only command-line tools. This is our fallback path when cgo isn't available.
37+
//
38+
// The strategy is as follows:
39+
//
40+
// 1. Run "security trust-settings-export" and "security
41+
// trust-settings-export -d" to discover the set of certs with some
42+
// user-tweaked trust policy. We're too lazy to parse the XML (at
43+
// least at this stage of Go 1.8) to understand what the trust
44+
// policy actually is. We just learn that there is _some_ policy.
45+
//
46+
// 2. Run "security find-certificate" to dump the list of system root
47+
// CAs in PEM format.
48+
//
49+
// 3. For each dumped cert, conditionally verify it with "security
50+
// verify-cert" if that cert was in the set discovered in Step 1.
51+
// Without the Step 1 optimization, running "security verify-cert"
52+
// 150-200 times takes 3.5 seconds. With the optimization, the
53+
// whole process takes about 180 milliseconds with 1 untrusted root
54+
// CA. (Compared to 110ms in the cgo path)
3055
func execSecurityRoots() (*CertPool, error) {
56+
hasPolicy, err := getCertsWithTrustPolicy()
57+
if err != nil {
58+
return nil, err
59+
}
60+
if debugExecDarwinRoots {
61+
println(fmt.Sprintf("crypto/x509: %d certs have a trust policy", len(hasPolicy)))
62+
}
63+
3164
cmd := exec.Command("/usr/bin/security", "find-certificate", "-a", "-p", "/System/Library/Keychains/SystemRootCertificates.keychain")
3265
data, err := cmd.Output()
3366
if err != nil {
3467
return nil, err
3568
}
3669

3770
var (
38-
mu sync.Mutex
39-
roots = NewCertPool()
71+
mu sync.Mutex
72+
roots = NewCertPool()
73+
numVerified int // number of execs of 'security verify-cert', for debug stats
4074
)
41-
add := func(cert *Certificate) {
42-
mu.Lock()
43-
defer mu.Unlock()
44-
roots.AddCert(cert)
45-
}
75+
4676
blockCh := make(chan *pem.Block)
4777
var wg sync.WaitGroup
78+
79+
// Using 4 goroutines to pipe into verify-cert seems to be
80+
// about the best we can do. The verify-cert binary seems to
81+
// just RPC to another server with coarse locking anyway, so
82+
// running 16 at a time for instance doesn't help at all. Due
83+
// to the "if hasPolicy" check below, though, we will rarely
84+
// (or never) call verify-cert on stock macOS systems, though.
85+
// The hope is that we only call verify-cert when the user has
86+
// tweaked their trust poliy. These 4 goroutines are only
87+
// defensive in the pathological case of many trust edits.
4888
for i := 0; i < 4; i++ {
4989
wg.Add(1)
5090
go func() {
5191
defer wg.Done()
5292
for block := range blockCh {
53-
verifyCertWithSystem(block, add)
93+
cert, err := ParseCertificate(block.Bytes)
94+
if err != nil {
95+
continue
96+
}
97+
sha1CapHex := fmt.Sprintf("%X", sha1.Sum(block.Bytes))
98+
99+
valid := true
100+
verifyChecks := 0
101+
if hasPolicy[sha1CapHex] {
102+
verifyChecks++
103+
if !verifyCertWithSystem(block, cert) {
104+
valid = false
105+
}
106+
}
107+
108+
mu.Lock()
109+
numVerified += verifyChecks
110+
if valid {
111+
roots.AddCert(cert)
112+
}
113+
mu.Unlock()
54114
}
55115
}()
56116
}
@@ -67,67 +127,118 @@ func execSecurityRoots() (*CertPool, error) {
67127
}
68128
close(blockCh)
69129
wg.Wait()
130+
131+
if debugExecDarwinRoots {
132+
mu.Lock()
133+
defer mu.Unlock()
134+
println(fmt.Sprintf("crypto/x509: ran security verify-cert %d times", numVerified))
135+
}
136+
70137
return roots, nil
71138
}
72139

73-
func verifyCertWithSystem(block *pem.Block, add func(*Certificate)) {
140+
func verifyCertWithSystem(block *pem.Block, cert *Certificate) bool {
74141
data := pem.EncodeToMemory(block)
75-
var cmd *exec.Cmd
76-
if needsTmpFiles() {
77-
f, err := ioutil.TempFile("", "cert")
78-
if err != nil {
79-
fmt.Fprintf(os.Stderr, "can't create temporary file for cert: %v", err)
80-
return
81-
}
82-
defer os.Remove(f.Name())
83-
if _, err := f.Write(data); err != nil {
84-
fmt.Fprintf(os.Stderr, "can't write temporary file for cert: %v", err)
85-
return
86-
}
87-
if err := f.Close(); err != nil {
88-
fmt.Fprintf(os.Stderr, "can't write temporary file for cert: %v", err)
89-
return
90-
}
91-
cmd = exec.Command("/usr/bin/security", "verify-cert", "-c", f.Name(), "-l")
92-
} else {
93-
cmd = exec.Command("/usr/bin/security", "verify-cert", "-c", "/dev/stdin", "-l")
94-
cmd.Stdin = bytes.NewReader(data)
95-
}
96-
if cmd.Run() == nil {
97-
// Non-zero exit means untrusted
98-
cert, err := ParseCertificate(block.Bytes)
99-
if err != nil {
100-
return
101-
}
102142

103-
add(cert)
143+
f, err := ioutil.TempFile("", "cert")
144+
if err != nil {
145+
fmt.Fprintf(os.Stderr, "can't create temporary file for cert: %v", err)
146+
return false
147+
}
148+
defer os.Remove(f.Name())
149+
if _, err := f.Write(data); err != nil {
150+
fmt.Fprintf(os.Stderr, "can't write temporary file for cert: %v", err)
151+
return false
152+
}
153+
if err := f.Close(); err != nil {
154+
fmt.Fprintf(os.Stderr, "can't write temporary file for cert: %v", err)
155+
return false
156+
}
157+
cmd := exec.Command("/usr/bin/security", "verify-cert", "-c", f.Name(), "-l", "-L")
158+
var stderr bytes.Buffer
159+
if debugExecDarwinRoots {
160+
cmd.Stderr = &stderr
161+
}
162+
if err := cmd.Run(); err != nil {
163+
if debugExecDarwinRoots {
164+
println(fmt.Sprintf("crypto/x509: verify-cert rejected %s: %q", cert.Subject.CommonName, bytes.TrimSpace(stderr.Bytes())))
165+
}
166+
return false
167+
}
168+
if debugExecDarwinRoots {
169+
println(fmt.Sprintf("crypto/x509: verify-cert approved %s", cert.Subject.CommonName))
104170
}
171+
return true
105172
}
106173

107-
var versionCache struct {
108-
sync.Once
109-
major int
110-
}
174+
// getCertsWithTrustPolicy returns the set of certs that have a
175+
// possibly-altered trust policy. The keys of the map are capitalized
176+
// sha1 hex of the raw cert.
177+
// They are the certs that should be checked against `security
178+
// verify-cert` to see whether the user altered the default trust
179+
// settings. This code is only used for cgo-disabled builds.
180+
func getCertsWithTrustPolicy() (map[string]bool, error) {
181+
set := map[string]bool{}
182+
td, err := ioutil.TempDir("", "x509trustpolicy")
183+
if err != nil {
184+
return nil, err
185+
}
186+
defer os.RemoveAll(td)
187+
run := func(file string, args ...string) error {
188+
file = filepath.Join(td, file)
189+
args = append(args, file)
190+
cmd := exec.Command("/usr/bin/security", args...)
191+
var stderr bytes.Buffer
192+
cmd.Stderr = &stderr
193+
if err := cmd.Run(); err != nil {
194+
// If there are no trust settings, the
195+
// `security trust-settings-export` command
196+
// fails with:
197+
// exit status 1, SecTrustSettingsCreateExternalRepresentation: No Trust Settings were found.
198+
// Rather than match on English substrings that are probably localized
199+
// on macOS, just treat interpret any failure as meaning that there are
200+
// no trust settings.
201+
if debugExecDarwinRoots {
202+
println(fmt.Sprintf("crypto/x509: exec %q: %v, %s", cmd.Args, err, stderr.Bytes()))
203+
}
204+
return nil
205+
}
111206

112-
// needsTmpFiles reports whether the OS is <= 10.11 (which requires real
113-
// files as arguments to the security command).
114-
func needsTmpFiles() bool {
115-
versionCache.Do(func() {
116-
release, err := syscall.Sysctl("kern.osrelease")
207+
f, err := os.Open(file)
117208
if err != nil {
118-
return
209+
return err
119210
}
120-
for i, c := range release {
121-
if c == '.' {
122-
release = release[:i]
211+
defer f.Close()
212+
213+
// Gather all the runs of 40 capitalized hex characters.
214+
br := bufio.NewReader(f)
215+
var hexBuf bytes.Buffer
216+
for {
217+
b, err := br.ReadByte()
218+
isHex := ('A' <= b && b <= 'F') || ('0' <= b && b <= '9')
219+
if isHex {
220+
hexBuf.WriteByte(b)
221+
} else {
222+
if hexBuf.Len() == 40 {
223+
set[hexBuf.String()] = true
224+
}
225+
hexBuf.Reset()
226+
}
227+
if err == io.EOF {
123228
break
124229
}
230+
if err != nil {
231+
return err
232+
}
125233
}
126-
major, err := strconv.Atoi(release)
127-
if err != nil {
128-
return
129-
}
130-
versionCache.major = major
131-
})
132-
return versionCache.major <= 15
234+
235+
return nil
236+
}
237+
if err := run("user", "trust-settings-export"); err != nil {
238+
return nil, fmt.Errorf("dump-trust-settings (user): %v", err)
239+
}
240+
if err := run("admin", "trust-settings-export", "-d"); err != nil {
241+
return nil, fmt.Errorf("dump-trust-settings (admin): %v", err)
242+
}
243+
return set, nil
133244
}

src/crypto/x509/root_darwin_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package x509
77
import (
88
"runtime"
99
"testing"
10+
"time"
1011
)
1112

1213
func TestSystemRoots(t *testing.T) {
@@ -15,13 +16,21 @@ func TestSystemRoots(t *testing.T) {
1516
t.Skipf("skipping on %s/%s, no system root", runtime.GOOS, runtime.GOARCH)
1617
}
1718

18-
sysRoots := systemRootsPool() // actual system roots
19+
t0 := time.Now()
20+
sysRoots := systemRootsPool() // actual system roots
21+
sysRootsDuration := time.Since(t0)
22+
23+
t1 := time.Now()
1924
execRoots, err := execSecurityRoots() // non-cgo roots
25+
execSysRootsDuration := time.Since(t1)
2026

2127
if err != nil {
2228
t.Fatalf("failed to read system roots: %v", err)
2329
}
2430

31+
t.Logf(" cgo sys roots: %v", sysRootsDuration)
32+
t.Logf("non-cgo sys roots: %v", execSysRootsDuration)
33+
2534
for _, tt := range []*CertPool{sysRoots, execRoots} {
2635
if tt == nil {
2736
t.Fatal("no system roots")

0 commit comments

Comments
 (0)