1# -*- coding: utf-8 -*-
2
3# nghttp2 - HTTP/2 C Library
4
5# Copyright (c) 2015 Tatsuhiro Tsujikawa
6
7# Permission is hereby granted, free of charge, to any person obtaining
8# a copy of this software and associated documentation files (the
9# "Software"), to deal in the Software without restriction, including
10# without limitation the rights to use, copy, modify, merge, publish,
11# distribute, sublicense, and/or sell copies of the Software, and to
12# permit persons to whom the Software is furnished to do so, subject to
13# the following conditions:
14
15# The above copyright notice and this permission notice shall be
16# included in all copies or substantial portions of the Software.
17
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
26# This program was translated from the program originally developed by
27# h2o project (https://github.com/h2o/h2o), written in Perl.  It had
28# the following copyright notice:
29
30# Copyright (c) 2015 DeNA Co., Ltd.
31#
32# Permission is hereby granted, free of charge, to any person obtaining a copy
33# of this software and associated documentation files (the "Software"), to
34# deal in the Software without restriction, including without limitation the
35# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
36# sell copies of the Software, and to permit persons to whom the Software is
37# furnished to do so, subject to the following conditions:
38#
39# The above copyright notice and this permission notice shall be included in
40# all copies or substantial portions of the Software.
41#
42# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
44# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
45# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
46# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
47# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
48# IN THE SOFTWARE.
49
50from __future__ import unicode_literals
51import argparse
52import io
53import os
54import os.path
55import re
56import shutil
57import subprocess
58import sys
59import tempfile
60
61# make this program work for both Python 3 and Python 2.
62try:
63    from urllib.parse import urlparse
64    stdout_bwrite = sys.stdout.buffer.write
65except ImportError:
66    from urlparse import urlparse
67    stdout_bwrite = sys.stdout.write
68
69
70def die(msg):
71    sys.stderr.write(msg)
72    sys.stderr.write('\n')
73    sys.exit(255)
74
75
76def tempfail(msg):
77    sys.stderr.write(msg)
78    sys.stderr.write('\n')
79    sys.exit(os.EX_TEMPFAIL)
80
81
82def run_openssl(args, allow_tempfail=False):
83    buf = io.BytesIO()
84    try:
85        p = subprocess.Popen(args, stdout=subprocess.PIPE)
86    except Exception as e:
87        die('failed to invoke {}:{}'.format(args, e))
88    try:
89        while True:
90            data = p.stdout.read()
91            if len(data) == 0:
92                break
93            buf.write(data)
94        if p.wait() != 0:
95            raise Exception('nonzero return code {}'.format(p.returncode))
96        return buf.getvalue()
97    except Exception as e:
98        msg = 'OpenSSL exitted abnormally: {}:{}'.format(args, e)
99        tempfail(msg) if allow_tempfail else die(msg)
100
101
102def read_file(path):
103    with open(path, 'rb') as f:
104        return f.read()
105
106
107def write_file(path, data):
108    with open(path, 'wb') as f:
109        f.write(data)
110
111
112def detect_openssl_version(cmd):
113    return run_openssl([cmd, 'version']).decode('utf-8').strip()
114
115
116def extract_ocsp_uri(cmd, cert_fn):
117    # obtain ocsp uri
118    ocsp_uri = run_openssl(
119        [cmd, 'x509', '-in', cert_fn, '-noout',
120         '-ocsp_uri']).decode('utf-8').strip()
121
122    if not re.match(r'^https?://', ocsp_uri):
123        die('failed to extract ocsp URI from {}'.format(cert_fn))
124
125    return ocsp_uri
126
127
128def save_issuer_certificate(issuer_fn, cert_fn):
129    # save issuer certificate
130    chain = read_file(cert_fn).decode('utf-8')
131    m = re.match(
132        r'.*?-----END CERTIFICATE-----.*?(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)',
133        chain, re.DOTALL)
134    if not m:
135        die('--issuer option was not used, and failed to extract issuer certificate from the certificate')
136    write_file(issuer_fn, (m.group(1) + '\n').encode('utf-8'))
137
138
139def send_and_receive_ocsp(respder_fn, cmd, cert_fn, issuer_fn, ocsp_uri,
140                          ocsp_host, openssl_version):
141    # obtain response (without verification)
142    sys.stderr.write('sending OCSP request to {}\n'.format(ocsp_uri))
143    args = [
144        cmd, 'ocsp', '-issuer', issuer_fn, '-cert', cert_fn, '-url', ocsp_uri,
145        '-noverify', '-respout', respder_fn
146    ]
147    ver = openssl_version.lower()
148    if ver.startswith('openssl 1.0.') or ver.startswith('libressl '):
149        args.extend(['-header', 'Host', ocsp_host])
150    resp = run_openssl(args, allow_tempfail=True)
151    return resp.decode('utf-8')
152
153
154def verify_response(cmd, tempdir, issuer_fn, respder_fn):
155    # verify the response
156    sys.stderr.write('verifying the response signature\n')
157
158    verify_fn = os.path.join(tempdir, 'verify.out')
159
160    # try from exotic options
161    allextra = [
162        # for comodo
163        ['-VAfile', issuer_fn],
164        # these options are only available in OpenSSL >= 1.0.2
165        ['-partial_chain', '-trusted_first', '-CAfile', issuer_fn],
166        # for OpenSSL <= 1.0.1
167        ['-CAfile', issuer_fn],
168    ]
169
170    for extra in allextra:
171        with open(verify_fn, 'w+b') as f:
172            args = [cmd, 'ocsp', '-respin', respder_fn]
173            args.extend(extra)
174            p = subprocess.Popen(args, stdout=f, stderr=f)
175            if p.wait() == 0:
176                # OpenSSL <= 1.0.1, openssl ocsp still returns exit
177                # code 0 even if verification was failed.  So check
178                # the error message in stderr output.
179                f.seek(0)
180                if f.read().decode('utf-8').find(
181                        'Response Verify Failure') != -1:
182                    continue
183                sys.stderr.write('verify OK (used: {})\n'.format(extra))
184                return True
185
186    sys.stderr.write(read_file(verify_fn).decode('utf-8'))
187    return False
188
189
190def fetch_ocsp_response(cmd, cert_fn, tempdir, issuer_fn=None):
191    openssl_version = detect_openssl_version(cmd)
192
193    sys.stderr.write(
194        'fetch-ocsp-response (using {})\n'.format(openssl_version))
195
196    ocsp_uri = extract_ocsp_uri(cmd, cert_fn)
197    ocsp_host = urlparse(ocsp_uri).netloc
198
199    if not issuer_fn:
200        issuer_fn = os.path.join(tempdir, 'issuer.crt')
201        save_issuer_certificate(issuer_fn, cert_fn)
202
203    respder_fn = os.path.join(tempdir, 'resp.der')
204    resp = send_and_receive_ocsp(
205        respder_fn, cmd, cert_fn, issuer_fn, ocsp_uri, ocsp_host,
206        openssl_version)
207
208    sys.stderr.write('{}\n'.format(resp))
209
210    # OpenSSL 1.0.2 still returns exit code 0 even if ocsp responder
211    # returned error status (e.g., trylater(3))
212    if resp.find('Responder Error:') != -1:
213        raise Exception('responder returned error')
214
215    if not verify_response(cmd, tempdir, issuer_fn, respder_fn):
216        tempfail('failed to verify the response')
217
218    # success
219    res = read_file(respder_fn)
220    stdout_bwrite(res)
221
222
223if __name__ == '__main__':
224    parser = argparse.ArgumentParser(
225        description=
226        '''The command issues an OCSP request for given server certificate, verifies the response and prints the resulting DER.''',
227        epilog=
228        '''The command exits 0 if successful, or 75 (EX_TEMPFAIL) on temporary error.  Other exit codes may be returned in case of hard errors.''')
229    parser.add_argument(
230        '--issuer',
231        metavar='FILE',
232        help=
233        'issuer certificate (if omitted, is extracted from the certificate chain)')
234    parser.add_argument('--openssl',
235                        metavar='CMD',
236                        help='openssl command to use (default: "openssl")',
237                        default='openssl')
238    parser.add_argument('certificate',
239                        help='path to certificate file to validate')
240    args = parser.parse_args()
241
242    tempdir = None
243    try:
244        # Python3.2 has tempfile.TemporaryDirectory, which has nice
245        # feature to delete its tree by cleanup() function.  We have
246        # to support Python2.7, so we have to do this manually.
247        tempdir = tempfile.mkdtemp()
248        fetch_ocsp_response(args.openssl, args.certificate, tempdir,
249                            args.issuer)
250    finally:
251        if tempdir:
252            shutil.rmtree(tempdir)
253