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        """
51        Returns the dict of certificate fingerprints observed in NSS,
52        or None if NSS is not available.
53        """
54        tmpdir = self.tmpdir
55
56        nss_shlib_glob = glob.glob('/usr/lib*/libnssckbi.so')
57        if len(nss_shlib_glob) == 0:
58            return None
59        elif len(nss_shlib_glob) > 1:
60            logging.warn("Found more than one copy of libnssckbi.so")
61
62        # Create new empty cert DB.
63        child = pexpect.spawn('"%s" -N -d %s' % (NSSCERTUTIL, tmpdir))
64        child.expect('Enter new password:')
65        child.sendline('foo')
66        child.expect('Re-enter password:')
67        child.sendline('foo')
68        child.close()
69
70        # Add the certs found in the compiled NSS shlib to a new module in DB.
71        cmd = ('"%s" -add testroots -libfile %s -dbdir %s' %
72               (NSSMODUTIL, nss_shlib_glob[0], tmpdir))
73        nssmodutil = pexpect.spawn(cmd)
74        nssmodutil.expect('\'q <enter>\' to abort, or <enter> to continue:')
75        nssmodutil.sendline('\n')
76        ret = utils.system_output(NSSMODUTIL + ' -list '
77                                  '-dbdir %s' % tmpdir)
78        self.assert_('2. testroots' in ret)
79
80        # Dump out the list of root certs.
81        all_certs = utils.system_output(NSSCERTUTIL +
82                                        ' -L -d %s -h all' % tmpdir,
83                                        retain_output=True)
84        certdict = {}  # A map of {SHA1_Fingerprint : CA_Nickname}.
85        cert_matches = NSS_ISSUER_RE.findall(all_certs)
86        logging.debug('NSS_ISSUER_RE.findall returned: %s', cert_matches)
87        for cert in cert_matches:
88            cert_dump = utils.system_output(NSSCERTUTIL +
89                                            ' -L -d %s -n '
90                                            '\"Builtin Object Token:%s\"' %
91                                            (tmpdir, cert), retain_output=True)
92            matches = FINGERPRINT_RE.findall(cert_dump)
93            for match in matches:
94                certdict[match] = cert
95        return certdict
96
97
98    def get_openssl_certs(self):
99        """Returns the dict of certificate fingerprints observed in OpenSSL."""
100        fingerprint_cmd = ' '.join([OPENSSL, 'x509', '-fingerprint',
101                                    '-issuer', '-noout',
102                                    '-in %s'])
103        certdict = {}  # A map of {SHA1_Fingerprint : CA_Nickname}.
104
105        for certfile in glob.glob(OPENSSL_CERT_GLOB):
106            f, i = utils.system_output(fingerprint_cmd % certfile,
107                                       retain_output=True).splitlines()
108            fingerprint = f.split('=')[1]
109            for field in i.split('/'):
110                items = field.split('=')
111                # Compensate for stupidly malformed issuer fields.
112                if len(items) > 1:
113                    if items[0] == 'CN':
114                        certdict[fingerprint] = items[1]
115                        break
116                    elif items[0] == 'O':
117                        certdict[fingerprint] = items[1]
118                        break
119                else:
120                    logging.warning('Malformed issuer string %s', i)
121            # Check that we found a name for this fingerprint.
122            if not fingerprint in certdict:
123                raise error.TestFail('Couldn\'t find issuer string for %s' %
124                                     fingerprint)
125        return certdict
126
127
128    def cert_perms_errors(self):
129        """Returns True if certificate files have bad permissions."""
130        # Acts as a regression check for crosbug.com/19848
131        has_errors = False
132        for certfile in glob.glob(OPENSSL_CERT_GLOB):
133            s = os.stat(certfile)
134            if s.st_uid != 0 or stat.S_IMODE(s.st_mode) != 0644:
135                logging.error("Bad permissions: %s",
136                              utils.system_output("ls -lH %s" % certfile))
137                has_errors = True
138
139        return has_errors
140
141
142    def run_once(self, opts=None):
143        """Test entry point.
144
145            Accepts 2 optional args, e.g. test_that --args="relaxed
146            baseline=foo".  Parses the args array and invokes the main test
147            method.
148
149           @param opts: string containing command line arguments.
150        """
151        args = {'baseline': DEFAULT_BASELINE}
152        if opts:
153            args.update(dict([[k, v] for (k, e, v) in
154                              [x.partition('=') for x in opts]]))
155
156        self.verify_rootcas(baseline_file=args['baseline'],
157                            exact_match=('relaxed' not in args))
158
159
160    def verify_rootcas(self, baseline_file=DEFAULT_BASELINE, exact_match=True):
161        """Verify installed Root CA's all appear on a specified whitelist.
162           Covers both NSS and OpenSSL.
163
164           @param baseline_file: name of baseline file to use in verification.
165           @param exact_match: boolean indicating if expected-but-missing CAs
166                               should cause test failure. Defaults to True.
167        """
168        testfail = False
169
170        # Dump certificate info and run comparisons.
171        seen = {}
172        nss_store = self.get_nss_certs()
173        openssl_store = self.get_openssl_certs()
174        if nss_store is not None:
175            seen['nss'] = nss_store
176        if openssl_store is not None:
177            seen['openssl'] = openssl_store
178
179        # Merge all 4 dictionaries (seen-nss, seen-openssl, expected-nss,
180        # and expected-openssl) into 1 so we have 1 place to lookup
181        # fingerprint -> comment for logging purposes.
182        expected = self.get_baseline_sets(baseline_file)
183        cert_details = {}
184        for store in seen.keys():
185            for certdict in [expected, seen]:
186                cert_details.update(certdict[store])
187                certdict[store] = set(certdict[store])
188
189        for store in seen.keys():
190            missing = expected[store].difference(seen[store])
191            unexpected = seen[store].difference(expected[store])
192            if unexpected or (missing and exact_match):
193                testfail = True
194                logging.error('Results for %s', store)
195                logging.error('Unexpected')
196                for i in unexpected:
197                    logging.error('"%s": "%s"', i, cert_details[i])
198                if exact_match:
199                    logging.error('Missing')
200                    for i in missing:
201                        logging.error('"%s": "%s"', i, cert_details[i])
202
203        # cert_perms_errors() call first to avoid short-circuiting.
204        # Short circuiting could mask additional failures that would
205        # require a second build/test iteration to uncover.
206        if self.cert_perms_errors() or testfail:
207            raise error.TestFail('Unexpected Root CA findings')
208