Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for external account binding; add OCSP responder #19

Merged
merged 3 commits into from
Aug 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!controller.py
!dns_server.py
!acme_tlsalpn.py
!ocsp.py
!create-pebble-config.py
!README.md
!LICENSE
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM golang:1.13-stretch as builder
# Install pebble
ARG PEBBLE_REMOTE=
ARG PEBBLE_CHECKOUT="746c32eb265131059a2a340388a4bc7c37405cfc"
ARG PEBBLE_CHECKOUT="05f79ba495ab6a27cbbc32e20b44afac52eb9914"
ENV GOPATH=/go
RUN go get -v -u github.com/letsencrypt/pebble/... && \
cd /go/src/github.com/letsencrypt/pebble && \
Expand All @@ -23,6 +23,6 @@ COPY --from=builder /go/bin /go/bin
COPY --from=builder /go/pkg /go/pkg
COPY --from=builder /go/src/github.com/letsencrypt/pebble/test /go/src/github.com/letsencrypt/pebble/test
# Setup controller.py and run.sh
ADD run.sh controller.py dns_server.py acme_tlsalpn.py create-pebble-config.py LICENSE LICENSE-acme README.md /root/
EXPOSE 5000 6000 14000
ADD run.sh controller.py dns_server.py acme_tlsalpn.py ocsp.py create-pebble-config.py LICENSE LICENSE-acme README.md /root/
EXPOSE 5000 14000
CMD [ "/bin/sh", "-c", "/root/run.sh" ]
34 changes: 25 additions & 9 deletions controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@

from functools import partial

from flask import Flask
from flask import request
from flask import Flask, request

from acme_tlsalpn import ALPNChallengeServer, gen_ss_cert

from OpenSSL import crypto

from dns_server import DNSServer
from ocsp import get_ocsp_response


app = Flask(__name__)
Expand Down Expand Up @@ -218,20 +219,35 @@ def get_root_certificate_minica():
return f.read()


@app.route('/root-certificate-for-ca/<int:index>')
def get_root_certificate_pebble(index):
def _pebble_urlopen(fragment, *args, **kwargs):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urllib.request.urlopen("https://localhost:15000/roots/{0}".format(index), context=ctx).read()
url = "https://localhost:15000{0}".format(fragment)
log('(internal call to {0})'.format(url))
return urllib.request.urlopen(url, *args, context=ctx, **kwargs)


@app.route('/root-certificate-for-ca/<int:index>')
def get_root_certificate_pebble(index):
return _pebble_urlopen("/roots/{0}".format(index)).read()


@app.route('/intermediate-certificate-for-ca/<int:index>')
def get_intermediate_certificate_pebble(index):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urllib.request.urlopen("https://localhost:15000/intermediates/{0}".format(index), context=ctx).read()
return _pebble_urlopen("/intermediates/{0}".format(index)).read()


@app.route('/ocsp/<string:data>', methods=['GET'])
def ocsp_get(data):
log('Received OCSP GET request')
return get_ocsp_response(base64.urlsafe_b64decode(data), _pebble_urlopen, log=log)


@app.route('/ocsp', methods=['POST'])
def ocsp_post():
log('Received OCSP POST request')
return get_ocsp_response(request.data, _pebble_urlopen, log=log)


if __name__ == "__main__":
Expand Down
11 changes: 10 additions & 1 deletion create-pebble-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@
"privateKey": "test/certs/localhost/key.pem",
"httpPort": 5000,
"tlsPort": 5001,
"ocspResponderURL": "http://{0}:6000".format(own_ip), # will be added later
"ocspResponderURL": "http://{0}:5000/ocsp".format(own_ip), # will be added later
"externalAccountBindingRequired": False,
"externalAccountMACKeys": {
"kid-1": "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W",
"kid-2": "b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH",
"kid-3": "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W",
"kid-4": "b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH",
"kid-5": "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W",
"kid-6": "b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH"
}
}
}

Expand Down
190 changes: 190 additions & 0 deletions ocsp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
# (c) 2018 Felix Fontein (@felixfontein) <felix@fontein.de>
#
# Written by Felix Fontein <felix@fontein.de>
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.

import datetime
import json
import os
import urllib
import traceback

from flask import Response

from cryptography import x509
from cryptography.x509 import ocsp
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization


SAMPLE_REQUEST_CACHE = {}


def _get_sample_request_for_root(root, hash_algorithm, pebble_urlopen):
'''
Returns an OCSP sample request for this root, together with the intermediate certificate and its key.
'''
cache_key = (root, hash_algorithm.name)
if cache_key in SAMPLE_REQUEST_CACHE:
return SAMPLE_REQUEST_CACHE[cache_key]

# Get hold of intermediate certificate with key
intermediate = x509.load_pem_x509_certificate(
pebble_urlopen("/intermediates/{0}".format(root)).read(),
backend=default_backend())
intermediate_key = serialization.load_pem_private_key(
pebble_urlopen("/intermediate-keys/{0}".format(root)).read(),
None,
backend=default_backend())

one_day = datetime.timedelta(1, 0, 0)
builder = x509.CertificateBuilder()
builder = builder.subject_name(intermediate.subject)
builder = builder.issuer_name(intermediate.subject)
builder = builder.not_valid_before(datetime.datetime.today() - one_day)
builder = builder.not_valid_after(datetime.datetime.today() + one_day)
builder = builder.serial_number(x509.random_serial_number())
builder = builder.public_key(intermediate.public_key())
certificate = builder.sign(
private_key=intermediate_key,
algorithm=hashes.SHA256(),
backend=default_backend())

req = ocsp.OCSPRequestBuilder()
req = req.add_certificate(certificate, intermediate, hash_algorithm)
req = req.build()

result = req, intermediate, intermediate_key
SAMPLE_REQUEST_CACHE[cache_key] = result
return result


RECOVATION_REASONS = {
1: x509.ReasonFlags.key_compromise,
2: x509.ReasonFlags.ca_compromise,
3: x509.ReasonFlags.affiliation_changed,
4: x509.ReasonFlags.superseded,
5: x509.ReasonFlags.cessation_of_operation,
6: x509.ReasonFlags.certificate_hold,
7: x509.ReasonFlags.privilege_withdrawn,
8: x509.ReasonFlags.aa_compromise,
}


def _get_ocsp_response(data, pebble_urlopen, log):
try:
ocsp_request = ocsp.load_der_ocsp_request(data)
except Exception:
log('Error while decoding OCSP request')
return ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.MALFORMED_REQUEST)

log('OCSP request for certificate # {0}'.format(ocsp_request.serial_number))

# Process possible extensions
nonce = None
for ext in ocsp_request.extensions:
if isinstance(ext.value, x509.OCSPNonce):
nonce = ext.value.nonce
continue
if ext.critical:
return ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.MALFORMED_REQUEST)

# Determine issuer
root_count = int(os.environ.get('PEBBLE_ALTERNATE_ROOTS') or '0') + 1
for root in range(root_count):
req, intermediate, intermediate_key = _get_sample_request_for_root(
root, ocsp_request.hash_algorithm, pebble_urlopen)
if req.issuer_key_hash == ocsp_request.issuer_key_hash and req.issuer_name_hash == ocsp_request.issuer_name_hash:
log('Identified intermediate certificate {0}'.format(intermediate.subject))
break
intermediate = None
intermediate_key = None
if intermediate is None or intermediate_key is None:
log(ocsp_request.issuer_key_hash, ocsp_request.issuer_name_hash)
log('Cannot identify intermediate certificate')
return ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.UNAUTHORIZED)

serial_hex = hex(ocsp_request.serial_number)[2:]
if len(serial_hex) % 2 == 1:
serial_hex = '0' + serial_hex
try:
url = pebble_urlopen("/cert-status-by-serial/{0}".format(serial_hex))
except urllib.error.HTTPError as e:
if e.code == 404:
log('Unknown certificate with # {0}'.format(ocsp_request.serial_number))
return ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.UNAUTHORIZED)
raise

data = json.loads(url.read())
log('Pebble result on certificate:', json.dumps(data, sort_keys=True, indent=2))

cert = x509.load_pem_x509_certificate(
data['Certificate'].encode('utf-8'), backend=default_backend())

now = datetime.datetime.now()
if data['Status'] == 'Revoked':
cert_status = ocsp.OCSPCertStatus.REVOKED
revoked_at = data.get('RevokedAt')
if revoked_at is not None:
revoked_at = ' '.join(revoked_at.split(' ')[:2]) # remove time zones
if '.' in revoked_at:
revoked_at = revoked_at[:revoked_at.index('.')] # remove milli- or nanoseconds
revoked_at = datetime.datetime.strptime(revoked_at, '%Y-%m-%d %H:%M:%S')
revocation_time = revoked_at,
revocation_reason = RECOVATION_REASONS.get(data.get('Reason'), x509.ReasonFlags.unspecified)
elif data['Status'] == 'Valid':
cert_status = ocsp.OCSPCertStatus.GOOD
revocation_time = None
revocation_reason = None
else:
log('Unknown certificate status "{0}"'.format(data['Status']))
return ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.INTERNAL_ERROR)

response = ocsp.OCSPResponseBuilder()
response = response.add_response(
cert=cert,
issuer=intermediate,
algorithm=ocsp_request.hash_algorithm,
cert_status=cert_status,
this_update=now,
next_update=None,
revocation_time=revocation_time,
revocation_reason=revocation_reason)
response = response.responder_id(
ocsp.OCSPResponderEncoding.HASH,
intermediate)
if nonce is not None:
response = response.add_extension(x509.OCSPNonce(nonce), False)
return response.sign(intermediate_key, hashes.SHA256())


def get_ocsp_response(data, pebble_urlopen, log=lambda *args: print(args)):
try:
response = _get_ocsp_response(data, pebble_urlopen, log=log)
except Exception as e:
log('Error while processing OCSP request: {0}'.format(e), traceback.format_exc())
response = ocsp.OCSPResponseBuilder.build_unsuccessful(
ocsp.OCSPResponseStatus.INTERNAL_ERROR)
return Response(
response.public_bytes(serialization.Encoding.DER),
mimetype='application/ocsp-response')
20 changes: 10 additions & 10 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Requirements
Flask==1.1.1
pyOpenSSL==19.0.0
dnslib==0.9.10
Flask==1.1.2
pyOpenSSL==19.1.0
dnslib==0.9.14
cryptography==3.0

# Implicit requirements to make Python part reproducable
Jinja2==2.10.3
Jinja2==2.11.2
MarkupSafe==1.1.1
Werkzeug==0.16.0
cffi==1.13.2
click==7.0
cryptography==2.8
Werkzeug==1.0.1
cffi==1.14.1
click==7.1.2
itsdangerous==1.1.0
pycparser==2.19
six==1.12.0
pycparser==2.20
six==1.15.0
8 changes: 4 additions & 4 deletions run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
set -e
# Our internal BIND (started by Controller) is the official DNS resolver for this container
echo nameserver 127.0.0.1 > /etc/resolv.conf
# Start controller in background
export CONTROLLER_PORT=5000
export GOPATH=/go
/usr/local/bin/python /root/controller.py &
# Make Pebble sleep at most 5 seconds between auth checks (default is 15 seconds)
export PEBBLE_VA_SLEEPTIME=5
# Add three alternate roots with intermediate certs
export PEBBLE_ALTERNATE_ROOTS=3
# Start controller in background
export CONTROLLER_PORT=5000
export GOPATH=/go
/usr/local/bin/python /root/controller.py &
# Create Pebble config
/usr/local/bin/python /root/create-pebble-config.py /go/src/github.com/letsencrypt/pebble/test/config/pebble-config.json
# Start Pebble
Expand Down