diff --git a/example.com.cron-task.sh b/example.com.cron-task.sh new file mode 100755 index 0000000..b1fffdb --- /dev/null +++ b/example.com.cron-task.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +./generate-and-sign-cert.sh \ + "acmeclient" \ + "/etc/nginx/certs/example.com" \ + "/CN=example.com/CN=www.example.com" \ + &>> "/var/log/acmeclient/example.com.log" + +service nginx reload diff --git a/generate-and-sign-cert.sh b/generate-and-sign-cert.sh new file mode 100755 index 0000000..f138340 --- /dev/null +++ b/generate-and-sign-cert.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -e + +ACMEUSER="$1" +CERTS_DIR="$2" +SUBJ="$3" + +if [ -z "$ACMEUSER" ] || [ -z "$CERTS_DIR" ] || [ -z "$SUBJ" ]; then + echo "Usage: $0 acmeuser /mysite/certs/dir/ /CN=mysite.com/CN=www.mysite.com" + exit 0 +fi + +BASE_DIR=$(cd `dirname "$0"`; pwd) +NAME=`date +%Y-%m-%d--%H-%M-%S--%N` +PREFIX="$CERTS_DIR/$NAME" + +echo " -------------------------------------------- " +echo " begin $PREFIX " +echo " -------------------------------------------- " + +mkdir -p "$CERTS_DIR" +openssl genrsa -out "$PREFIX.key" 4096 +openssl req -new -sha512 -key "$PREFIX.key" -out "$PREFIX.csr" -subj "$SUBJ" +sudo -u "$ACMEUSER" "$BASE_DIR/sign-cert.py" "$PREFIX.csr" "$PREFIX.crt" + +echo "compare modulus" +$MUDULUS_CRT=`openssl x509 -noout -modulus -in "$PREFIX.csr"` +$MUDULUS_KEY=`openssl rsa -noout -modulus -in "$PREFIX.key"` +if [ "$MODULUS_CRT" != "$MUDULUS_KEY" ]; then + echo "ERROR: modulus of certificate do not matches modulus of key" + exit 1 +fi +echo "ok" + +cd "$CERTS_DIR" +ln -fs "$PREFIX.key" "private.key" +ln -fs "$PREFIX.key" "public.crt" + +echo " -------------------------------------------- " +echo " done $PREFIX " +echo " -------------------------------------------- " diff --git a/generate-api-key.py b/generate-api-key.py new file mode 100755 index 0000000..b44410d --- /dev/null +++ b/generate-api-key.py @@ -0,0 +1,19 @@ +#!/usr/bin/python3 + +import sys +from jwcrypto import jwk + +if len(sys.argv) != 2 or (len(sys.argv) == 2 and sys.argv[1] == '--help'): + print('Usage: ' + sys.argv[0] + ' /path/to/new/api/key.json') + quit() + +filename = sys.argv[1] + +print('generate') +key = jwk.JWK(generate = 'RSA', size = 4096) +print(' generated fingerprint: ' + str(key.thumbprint())) +print(' save generated key to file: ' + filename) +with open(filename, 'w') as f: + f.write( key.export() ) +print('done') + diff --git a/main.py b/main.py deleted file mode 100755 index c956892..0000000 --- a/main.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/python3 - -import os -import time -import json -import base64 -import requests -from cryptorgaphy import x509 -from cryptography.hazmat.backends import default_backend -from jwcrypto import jwk, jwt - - -api_url = 'https://acme-staging-v02.api.letsencrypt.org/directory' -api_client_key_file = './api-client-key.json' -answers_prefix = '/tmp/' -csr_file = './cert.csr' -out_cert_file = './cert.crt' - - -class Session: - def __init__(self, url, key, csr, answers_prefix, out_cert_file): - self.url = str(url) - self.key = key - self.csr = csr - self.answers_prefix = str(answers_prefix) - self.out_cert_file = str(out_cert_file) - - - # step 1 - # in: self.url - # out: self.url_newnonce, self.url_newaccount, self.url_neworder - def fetch_directory(self): - print('fetch directory') - print(' get ' + self.url) - r = requests.get(self.url) - if r.status_code != 200: - raise r - json = r.json() - self.url_newnonce = str(json['newNonce']) - self.url_newaccount = str(json['newAccount']) - self.url_neworder = str(json['newOrder']) - print(' url for new nonce: ' + self.url_newnonce) - print(' url for new account: ' + self.url_newaccount) - print(' url for new order: ' + self.url_neworder) - - - # step 2 - # in: self.url_newnonce - # out: self.nonce - def fetch_nonce(self): - print('fetch nonce') - print(' head ' + self.url_newnonce) - r = requests.head(self.url_newnonce) - if r.status_code != 200: - raise r - self.nonce = str(r.headers['Replay-Nonce']) - print(' nonce: ' + self.nonce) - - - # common method using in all following steps to send signed requests - # in: self.nonce, self.key, self.kid - # out: self.nonce - # raises: requests.Response - # returns: requests.Response - def post_jws_request(url, claims, expected_status_code): - print(' post jws ' + url) - - header = { - 'alg': 'RS256', - 'nonce': self.nonce, - 'url': url } - if self.kid is None: - header['jwk'] = json.loads(self.key.export_public()) - else: - header['kid'] = self.kid - - token = jwt.JWT(header, claims) - token.make_signed_token(self.key) - data = token.serialize(False) - - headers = { 'Content-Type': 'application/jose+json' } - r = requests.post(url, headers = headers, data = data) - if r.status_code != expected_status_code: - print(' wrong status code ' + str(r.status_code)) - print(' expected ' + str(expected_status_code)) - raise r - self.nonce = str(r.headers['Replay-Nonce']) - print(' nonce: ' + self.nonce) - return r - - - # step 3 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_newaccount - # out: self.kid - def fetch_account(self): - print('fetch account') - self.kid = None - claims = { 'termsOfServiceAgreed': True } - r = self.post_jws_request(url, claims, 201) - self.kid = str(r.headers['Location']) - print(' kid: ' + self.kid) - - - # step 4 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_neworder - # out: self.url_order, self.url_authorizations, self.url_finalize, self.url_cert - def create_order(self): - print('create order') - - identifiers = [] - for attribute in csr.subject: - if attribute.oid == x509.NameOID.COMMON_NAME: - identifiers.append({ - 'type': 'dns', - 'value': str(attribute.value) }) - print(' csr name: ' + str(attribute.value)) - else: - print(' WARNING: not supported attribute in csr: ' + str(attribute.rfc4514_string())) - - claims = { 'identifiers': identifiers } - r = self.post_jws_request(url, claims, 201) - - self.url_order = str(r.headers['Location']) - - json = r.json() - self.url_authorizations = [] - for x in json['authorizations'] - self.url_authorizations.append(str(x)) - print(' url for authorization: ' + str(x)) - self.url_finalize = str(json['finalize']) - self.url_cert = str(json['certificate']) - print(' url for finalize: ' + self.url_finalize) - print(' url for cert: ' + self.url_cert) - - - # step 5.1 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # returns: url_chall (str), token (str) - def fetch_authorization(self, url_authorization): - print('fetch authorization') - r = self.post_jws_request(url_authorization, '', 200) - json = r.json() - print(' identifier type: ' + str(json['identifier']['type'])) - print(' identifier value: ' + str(json['identifier']['value'])) - - url_chall = None - token = None - for x in json['challenges'] - challenge = str(x['type']) - if challenge == 'http-01': - print(' suitable challenge: ' + challenge) - url_chall = str(x['url']) - token = str(x['token']) - break - assert(not url_chall is None) - assert(not token is None) - - print(' url for challenge: ' + url_chall) - print(' url for challenge: ' + url_chall) - print(' challenge token: ' + token) - - print(' verify token') - if len(token) > 256: - raise Exception('token too long, maximum length is 256') - valid_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' - for char in token: - if not char in valid_chars: - raise Exception('wrong token, allowed following chars only: ' + valid_chars) - - return url_chall, token - - - # step 5.2 - # in: self.answers_prefix - def prepare_challenge_answer(self, token): - print('prepare challenge answer') - answer = token + '.' + str(key.thumbprint(jwk.hashes.SHA256())) - print(' answer: ' + answer) - filename = answers_prefix + token - print(' write answer to file: ' + filename) - with open(filename, 'w') as f: - f.write( answer ) - - - # step 5.3 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - def notify_challenge_ready(self, url_chall): - print('notify that challenge is ready') - self.post_jws_request(url_chall, dict()) - - - # step 5.4 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - def wait_authorization(self, url_authorization): - for i in range(0, 10): - print('wait 5 seconds') - time.sleep(5) - print('check authorization') - r = self.post_jws_request(url_authorization, '', 200) - json = r.json() - status = str(json['status']) - print(' authorization status: ' + status) - if status == 'valid': - print('authorization success') - return - assert(status == 'pending') - raise Exception('authorization was not happened') - - - # step 5 all - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_authorizations - def process_authorizations(self): - print('process authorizations') - for url_authorization in self.url_authorizations: - url_chall, token = self.fetch_authorization(url_authorization) - self.prepare_challenge_answer(token) - self.notify_challenge_ready(url_chall) - self.wait_authorization(url_authorization) - print('all authorizations success') - - - # step 6 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_order - def wait_order_ready(self): - for i in range(0, 10): - print('wait 5 seconds') - time.sleep(5) - print('check order status') - r = self.post_jws_request(self.url_order, '', 200) - json = r.json() - status = str(json['status']) - print(' order status: ' + status) - if status == 'ready': - print('order is ready') - return - assert(status == 'pending') - raise Exception('order was not became ready') - - - # step 7 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.csr, self.url_finalize - def finalize_order(self): - print('finalize order (send CSR)') - csr_data = base64.urlsafe_b64encode( csr.public_bytes(Encoding.DER) ).decode().replace('=', '') - print(' csr adta ' + csr_data) - claims = { 'csr': csr_data } - self.post_jws_request(self.url_finalize, claims) - if r.status_code != 200: - raise r - - - # step 8 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_order - def wait_order_valid(self): - for i in range(0, 10): - print('wait 5 seconds') - time.sleep(5) - print('check order status') - r = self.post_jws_request(self.url_order, '', 200) - json = r.json() - status = str(json['status']) - print(' order status: ' + status) - if status == 'valid': - print('order success') - return - assert(status == 'processing') - raise Exception('order was not became valid') - - - # step 9 - # uses: post_jws_request (also see 'in', 'out' and 'raises' there) - # in: self.url_cert - def fetch_certificate(self): - print('fetch certificate') - r = self.post_jws_request(self.url_cert, '') - cert = r.text - - print('downloaded certificate:') - print(cert) - if len(cert) > 10000000: - raise Exception('certificate too long, maximum length is 10000000') - - print('write certificate to file: ' + out_cert_file) - with open(out_cert_file, 'w') as f: - f.write(cert) - - - # all steps - def run(self): - print('run') - self.fetch_directory() - self.fetch_nonce() - self.fetch_account() - self.create_order() - self.process_authorizations() - self.wait_order_ready() - self.finalize_order() - self.wait_order_valid() - self.fetch_certificate() - print('done') - - - -print('hello') - -if not os.path.isfile(api_client_key_file): - print('api client key file not found: ' + api_client_key_file) - print('generate') - key = jwk.JWK(generate = 'RSA', size = 4096) - print(' generated key id: ' + str(key.key_id)) - print(' generated fingerprint: ' + str(key.thumbprint())) - print(' save generated kay to file: ' + api_client_key_file) - with open(api_client_key_file, 'w') as f: - f.write( key.export() ) - -print('load api client key from file: ' + api_client_key_file) -with open(api_client_key_file, 'r') as f: - key = jwk.JWK( **json.loads(f.read()) ) -print(' loaded key id: ' + str(key.key_id)) -print(' loaded fingerprint: ' + str(key.thumbprint())) - - -print('load CSF from file: ' + csr_file) -with open(csr_file, 'rb') as f: - csr = x509.load_pem_x509_csr(f.read(), default_backend()) - -session = Session(api_url, key, csr, answers_prefix, out_cert_file) -session.run() - diff --git a/sign-cert.py b/sign-cert.py new file mode 100755 index 0000000..c9db408 --- /dev/null +++ b/sign-cert.py @@ -0,0 +1,377 @@ +#!/usr/bin/python3 + +import sys +import time +import json +import base64 +import requests +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from jwcrypto import jwk, jws + + +api_url = 'https://acme-staging-v02.api.letsencrypt.org/directory' +api_client_key_file = '/home/bw/work/dev/acmeclient-data/api-client-key.json' +answers_prefix = '/var/www/html/.well-known/acme-challenge/' + + +class Session: + def __init__(self, url, key, csr, answers_prefix, out_cert_file): + self.url = str(url) + self.key = key + self.csr = csr + self.answers_prefix = str(answers_prefix) + self.out_cert_file = str(out_cert_file) + + + # common method using in many steps to send unsigned requests + # in: self.nonce, self.key, self.kid + # out: self.nonce + # returns: requests.Response + def get_request(self, url, expected_status_codes): + print(' post signed ' + url) + + header = { + 'alg': 'RS256', + 'nonce': self.nonce, + 'url': url } + if self.kid is None: + header['jwk'] = json.loads(self.key.export_public()) + else: + header['kid'] = self.kid + + signer = jws.JWS(json.dumps(claims)) + signer.add_signature(self.key, protected = json.dumps(header)) + data = signer.serialize() + + headers = { 'Content-Type': 'application/jose+json' } + r = requests.post(url, headers = headers, data = data) + if not r.status_code in expected_status_codes: + print(' wrong status code ' + str(r.status_code)) + print(' expected ' + str(expected_status_codes)) + print('response headers') + print(r.headers) + print('response body') + print(r.text) + raise Exception('unexpected server answer') + self.nonce = str(r.headers['Replay-Nonce']) + print(' nonce: ' + self.nonce) + return r + + + # common method using in many steps to send signed requests + # in: self.nonce, self.key, self.kid + # out: self.nonce + # returns: requests.Response + def post_signed_request(self, url, claims, expected_status_codes): + print(' post signed ' + url) + + header = { + 'alg': 'RS256', + 'nonce': self.nonce, + 'url': url } + if self.kid is None: + header['jwk'] = json.loads(self.key.export_public()) + else: + header['kid'] = self.kid + + signer = jws.JWS(json.dumps(claims)) + signer.add_signature(self.key, protected = json.dumps(header)) + data = signer.serialize() + + headers = { 'Content-Type': 'application/jose+json' } + r = requests.post(url, headers = headers, data = data) + if not r.status_code in expected_status_codes: + print(' wrong status code ' + str(r.status_code)) + print(' expected ' + str(expected_status_codes)) + print('response headers') + print(r.headers) + print('response body') + print(r.text) + raise Exception('unexpected server answer') + self.nonce = str(r.headers['Replay-Nonce']) + print(' nonce: ' + self.nonce) + return r + + + # step 1 + # in: self.url + # out: self.url_newnonce, self.url_newaccount, self.url_neworder + def fetch_directory(self): + print('fetch directory') + print(' get ' + self.url) + r = requests.get(self.url) + if r.status_code != 200: + print('response status code: ' + r.status_code) + print('response headers') + print(r.headers) + print('response body') + print(r.text) + raise Exception('unexpected server answer') + json = r.json() + self.url_newnonce = str(json['newNonce']) + self.url_newaccount = str(json['newAccount']) + self.url_neworder = str(json['newOrder']) + print(' url for new nonce: ' + self.url_newnonce) + print(' url for new account: ' + self.url_newaccount) + print(' url for new order: ' + self.url_neworder) + + + # step 2 + # in: self.url_newnonce + # out: self.nonce + def fetch_nonce(self): + print('fetch nonce') + print(' head ' + self.url_newnonce) + r = requests.head(self.url_newnonce) + if r.status_code != 200: + print('response status code: ' + r.status_code) + print('response headers') + print(r.headers) + print('response body') + print(r.text) + raise Exception('unexpected server answer') + self.nonce = str(r.headers['Replay-Nonce']) + print(' nonce: ' + self.nonce) + + + # step 3 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_newaccount + # out: self.kid + def fetch_account(self): + print('fetch account') + self.kid = None + claims = { 'termsOfServiceAgreed': True } + r = self.post_jws_request(self.url_newaccount, claims, [200, 201]) + self.kid = str(r.headers['Location']) + print(' kid: ' + self.kid) + + + # step 4 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_neworder + # out: self.url_order, self.url_authorizations, self.url_finalize + def create_order(self): + print('create order') + + identifiers = [] + for attribute in csr.subject: + if attribute.oid == x509.NameOID.COMMON_NAME: + identifiers.append({ + 'type': 'dns', + 'value': str(attribute.value) }) + print(' csr name: ' + str(attribute.value)) + else: + print(' WARNING: not supported attribute in csr: ' + str(attribute.rfc4514_string())) + + claims = { 'identifiers': identifiers } + r = self.post_jws_request(self.url_neworder, claims, [201]) + + self.url_order = str(r.headers['Location']) + + json = r.json() + self.url_authorizations = [] + for x in json['authorizations']: + self.url_authorizations.append(str(x)) + print(' url for authorization: ' + str(x)) + self.url_finalize = str(json['finalize']) + print(' url for finalize: ' + self.url_finalize) + + + # step 5.1 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # returns: url_chall (str), token (str) + def fetch_authorization(self, url_authorization): + print('fetch authorization') + r = self.post_jws_request(url_authorization, dict(), [200]) + json = r.json() + print(' identifier type: ' + str(json['identifier']['type'])) + print(' identifier value: ' + str(json['identifier']['value'])) + + url_chall = None + token = None + for x in json['challenges']: + challenge = str(x['type']) + if challenge == 'http-01': + print(' suitable challenge: ' + challenge) + url_chall = str(x['url']) + token = str(x['token']) + break + assert(not url_chall is None) + assert(not token is None) + + print(' url for challenge: ' + url_chall) + print(' url for challenge: ' + url_chall) + print(' challenge token: ' + token) + + print(' verify token') + if len(token) > 256: + raise Exception('token too long, maximum length is 256') + valid_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + for char in token: + if not char in valid_chars: + raise Exception('wrong token, allowed following chars only: ' + valid_chars) + + return url_chall, token + + + # step 5.2 + # in: self.answers_prefix + def prepare_challenge_answer(self, token): + print('prepare challenge answer') + answer = token + '.' + str(key.thumbprint(jwk.hashes.SHA256())) + print(' answer: ' + answer) + filename = answers_prefix + token + print(' write answer to file: ' + filename) + with open(filename, 'w') as f: + f.write( answer ) + + + # step 5.3 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + def notify_challenge_ready(self, url_chall): + print('notify that challenge is ready') + self.post_jws_request(url_chall, dict(), [200]) + + + # step 5.4 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + def wait_authorization(self, url_authorization): + for i in range(0, 10): + print('wait 5 seconds') + time.sleep(5) + print('check authorization') + r = self.post_jws_request(url_authorization, '', [200]) + json = r.json() + status = str(json['status']) + print(' authorization status: ' + status) + if status == 'valid': + print('authorization success') + return + assert(status == 'pending') + raise Exception('authorization was not happened') + + + # step 5 all + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_authorizations + def process_authorizations(self): + print('process authorizations') + for url_authorization in self.url_authorizations: + url_chall, token = self.fetch_authorization(url_authorization) + self.prepare_challenge_answer(token) + self.notify_challenge_ready(url_chall) + self.wait_authorization(url_authorization) + print('all authorizations success') + + + # step 6 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_order + def wait_order_ready(self): + for i in range(0, 10): + print('wait 5 seconds') + time.sleep(5) + print('check order status') + r = self.post_jws_request(self.url_order, '', [200]) + json = r.json() + status = str(json['status']) + print(' order status: ' + status) + if status == 'ready': + self.url_cert = str(json['certificate']) + print(' url for cert: ' + self.url_cert) + print('order is ready') + return + assert(status == 'pending') + raise Exception('order was not became ready') + + + # step 7 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.csr, self.url_finalize + def finalize_order(self): + print('finalize order (send CSR)') + csr_data = base64.urlsafe_b64encode( csr.public_bytes(Encoding.DER) ).decode().replace('=', '') + print(' csr adta ' + csr_data) + claims = { 'csr': csr_data } + self.post_jws_request(self.url_finalize, claims, [200]) + + + # step 8 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_order + # out: self.url_cert + def wait_order_valid(self): + for i in range(0, 10): + print('wait 5 seconds') + time.sleep(5) + print('check order status') + r = self.post_jws_request(self.url_order, '', [200]) + json = r.json() + status = str(json['status']) + print(' order status: ' + status) + if status == 'valid': + print('order success') + return + assert(status == 'processing') + raise Exception('order was not became valid') + + + # step 9 + # uses: post_jws_request (also see 'in', 'out' and 'raises' there) + # in: self.url_cert + def fetch_certificate(self): + print('fetch certificate') + r = self.post_jws_request(self.url_cert, '', [200]) + + cert = x509.load_der_x509_certificate(r.text, default_backend()) + pem = issuer_cert.public_bytes(serialization.Encoding.PEM).decode() + + print('downloaded certificate:') + print(pem) + print('write certificate to file: ' + out_cert_file) + with open(out_cert_file, 'w') as f: + f.write(pem) + + + # all steps + def run(self): + print('run') + self.fetch_directory() + self.fetch_nonce() + self.fetch_account() + self.create_order() + self.process_authorizations() + self.wait_order_ready() + self.finalize_order() + self.wait_order_valid() + self.fetch_certificate() + print('done') + + + +if len(sys.argv) != 3 or (len(sys.argv) == 2 and sys.argv[1] == '--help'): + print('Usage: ' + sys.argv[0] + ' /path/to/certificate/sign/request.csr /path/to/out/certificate.crt') + quit() + + +csr_file = str(sys.argv[1]) +out_cert_file = str(sys.argv[2]) + +print('hello') + +print('load api client key from file: ' + api_client_key_file) +with open(api_client_key_file, 'r') as f: + key = jwk.JWK( **json.loads(f.read()) ) +print(' loaded fingerprint: ' + str(key.thumbprint())) + + +print('load CSF from file: ' + csr_file) +with open(csr_file, 'rb') as f: + csr = x509.load_pem_x509_csr(f.read(), default_backend()) + +session = Session(api_url, key, csr, answers_prefix, out_cert_file) +session.run() +