security_RootCA.py revision 4bcdd2ba40de41d306d0ece671f076660582a145
1# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import glob, json, logging, os, re, stat
6
7from autotest_lib.client.bin import test, utils
8from autotest_lib.client.common_lib import error
9from autotest_lib.client.common_lib import pexpect
10
11
12DEFAULT_BASELINE = 'baseline'
13
14FINGERPRINT_RE = re.compile(r'Fingerprint \(SHA1\):\n\s+(\b[:\w]+)\b')
15NSS_ISSUER_RE = re.compile(r'Object Token:(.+?)\s+C,.?,.?')
16
17NSSCERTUTIL = '/usr/local/bin/certutil'
18NSSMODUTIL = '/usr/local/bin/modutil'
19OPENSSL = '/usr/bin/openssl'
20
21# This glob pattern is coupled to the snprintf() format in
22# get_cert_by_subject() in crypto/x509/by_dir.c in the openssl
23# sources.  In theory the glob can catch files not created by that
24# snprintf(); such file names probably shouldn't be allowed to exist
25# anyway.
26OPENSSL_CERT_GLOB = '/etc/ssl/certs/' + '[0-9a-f]' * 8 + '.*'
27
28
29class security_RootCA(test.test):
30    """Verifies that the root CAs trusted by both nss and openssl
31       match the expected set."""
32    version = 1
33
34    def get_baseline_sets(self, baseline_file):
35        """Returns a dictionary of sets. The keys are the names of
36           the ssl components and the values are the sets of fingerprints
37           we expect to find in that component's Root CA list.
38
39           @param baseline_file: name of JSON file containing baseline.
40        """
41        baselines = {'nss': {}, 'openssl': {}}
42        baseline_file = open(os.path.join(self.bindir, baseline_file))
43        raw_baselines = json.load(baseline_file)
44        for i in ['nss', 'openssl']:
45            baselines[i].update(raw_baselines[i])
46            baselines[i].update(raw_baselines['both'])
47        return baselines
48
49    def get_nss_certs(self):
50        """Returns a dict of certificate fingerprints observed in nss."""
51        tmpdir = self.tmpdir
52
53        # Create new empty cert DB.
54        child = pexpect.spawn('"%s" -N -d %s' % (NSSCERTUTIL, tmpdir))
55        child.expect('Enter new password:')
56        child.sendline('foo')
57        child.expect('Re-enter password:')
58        child.sendline('foo')
59        child.close()
60
61        # Add the certs found in the compiled NSS shlib to a new module in DB.
62        cmd = ('"%s" -add testroots -libfile %s -dbdir %s' %
63               (NSSMODUTIL, glob.glob('/usr/lib*/libnssckbi.so')[0], tmpdir))
64        nssmodutil = pexpect.spawn(cmd)
65        nssmodutil.expect('\'q <enter>\' to abort, or <enter> to continue:')
66        nssmodutil.sendline('\n')
67        ret = utils.system_output(NSSMODUTIL + ' -list '
68                                  '-dbdir %s' % tmpdir)
69        self.assert_('2. testroots' in ret)
70
71        # Dump out the list of root certs.
72        all_certs = utils.system_output(NSSCERTUTIL +
73                                        ' -L -d %s -h all' % tmpdir,
74                                        retain_output=True)
75        certdict = {}  # A map of {SHA1_Fingerprint : CA_Nickname}.
76        cert_matches = NSS_ISSUER_RE.findall(all_certs)
77        logging.debug('NSS_ISSUER_RE.findall returned: %s', cert_matches)
78        for cert in cert_matches:
79            cert_dump = utils.system_output(NSSCERTUTIL +
80                                            ' -L -d %s -n '
81                                            '\"Builtin Object Token:%s\"' %
82                                            (tmpdir, cert), retain_output=True)
83            matches = FINGERPRINT_RE.findall(cert_dump)
84            for match in matches:
85                certdict[match] = cert
86        return certdict
87
88
89    def get_openssl_certs(self):
90        """Returns the dict of certificate fingerprints observed in openssl."""
91        fingerprint_cmd = ' '.join([OPENSSL, 'x509', '-fingerprint',
92                                    '-issuer', '-noout',
93                                    '-in %s'])
94        certdict = {}  # A map of {SHA1_Fingerprint : CA_Nickname}.
95
96        for certfile in glob.glob(OPENSSL_CERT_GLOB):
97            f, i = utils.system_output(fingerprint_cmd % certfile,
98                                       retain_output=True).splitlines()
99            fingerprint = f.split('=')[1]
100            for field in i.split('/'):
101                items = field.split('=')
102                # Compensate for stupidly malformed issuer fields.
103                if len(items) > 1:
104                    if items[0] == 'CN':
105                        certdict[fingerprint] = items[1]
106                        break
107                    elif items[0] == 'O':
108                        certdict[fingerprint] = items[1]
109                        break
110                else:
111                    logging.warning('Malformed issuer string %s', i)
112            # Check that we found a name for this fingerprint.
113            if not fingerprint in certdict:
114                raise error.TestFail('Couldn\'t find issuer string for %s' %
115                                     fingerprint)
116        return certdict
117
118
119    def cert_perms_errors(self):
120        """Returns True if certificate files have bad permissions."""
121        # Acts as a regression check for crosbug.com/19848
122        has_errors = False
123        for certfile in glob.glob(OPENSSL_CERT_GLOB):
124            s = os.stat(certfile)
125            if s.st_uid != 0 or stat.S_IMODE(s.st_mode) != 0644:
126                logging.error("Bad permissions: %s",
127                              utils.system_output("ls -lH %s" % certfile))
128                has_errors = True
129
130        return has_errors
131
132
133    def run_once(self, opts=None):
134        """Entry point for command line (run_remote_test) use. Accepts 2
135           optional args, e.g. run_remote_test --args="relaxed baseline=foo".
136           Parses the args array and invokes the main test method.
137
138           @param opts: string containing command line arguments.
139        """
140        args = {'baseline': DEFAULT_BASELINE}
141        if opts:
142            args.update(dict([[k, v] for (k, e, v) in
143                              [x.partition('=') for x in opts]]))
144
145        self.verify_rootcas(baseline_file=args['baseline'],
146                            exact_match=('relaxed' not in args))
147
148
149    def verify_rootcas(self, baseline_file=DEFAULT_BASELINE, exact_match=True):
150        """Verify installed Root CA's all appear on a specified whitelist.
151           Covers both nss and openssl.
152
153           @param baseline_file: name of baseline file to use in verification.
154           @param exact_match: boolean indicating if expected-but-missing CAs
155                               should cause test failure. Defaults to True.
156        """
157        testfail = False
158
159        # Dump certificate info and run comparisons.
160        seen = {}
161        seen['nss'] = self.get_nss_certs()
162        seen['openssl'] = self.get_openssl_certs()
163        # Merge all 4 dictionaries (seen-nss, seen-openssl, expected-nss,
164        # and expected-openssl) into 1 so we have 1 place to lookup
165        # fingerprint -> comment for logging purposes.
166        expected = self.get_baseline_sets(baseline_file)
167        cert_details = {}
168        for certdict in [expected, seen]:
169            for i in ['openssl', 'nss']:
170                cert_details.update(certdict[i])
171                certdict[i] = set(certdict[i])
172
173        for lib in seen.keys():
174            missing = expected[lib].difference(seen[lib])
175            unexpected = seen[lib].difference(expected[lib])
176            if unexpected or (missing and exact_match):
177                testfail = True
178                logging.error('Results for %s', lib)
179                logging.error('Unexpected')
180                for i in unexpected:
181                    logging.error('"%s": "%s"', i, cert_details[i])
182                if exact_match:
183                    logging.error('Missing')
184                    for i in missing:
185                        logging.error('"%s": "%s"', i, cert_details[i])
186
187        # cert_perms_errors() call first to avoid short-circuiting.
188        # Short circuiting could mask additional failures that would
189        # require a second build/test iteration to uncover.
190        if self.cert_perms_errors() or testfail:
191            raise error.TestFail('Unexpected Root CA findings')
192