Blame sign-cert.py

Ivan Mahonin 7a50dc
#!/usr/bin/python3
Ivan Mahonin 7a50dc
Ivan Mahonin 4e2d47
import os
Ivan Mahonin cc2683
import sys
Ivan Mahonin 7a50dc
import time
Ivan Mahonin 7a50dc
import json
Ivan Mahonin 7a50dc
import requests
Ivan Mahonin cc2683
from cryptography import x509
Ivan Mahonin b6b0ba
from cryptography.hazmat.backends import default_backend
Ivan Mahonin 7bd19b
from cryptography.hazmat.primitives.serialization import Encoding
Ivan Mahonin cc2683
from jwcrypto import jwk, jws
Ivan Mahonin 7bd19b
from jwcrypto.common import base64url_encode
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
Ivan Mahonin 96bebd
#api_url             = 'https://acme-staging-v02.api.letsencrypt.org/directory'
Ivan Mahonin 96bebd
api_url             = 'https://acme-v02.api.letsencrypt.org/directory'
Ivan Mahonin 882d94
api_client_key_file = '/home/acmeclient/acmeclient-root/api-client-key.json'
Ivan Mahonin 882d94
answers_prefix      = '/home/acmeclient/acmeclient-root/challenge/'
Ivan Mahonin 882d94
Ivan Mahonin 882d94
Ivan Mahonin 882d94
def log(*args, **kwargs):
Ivan Mahonin 882d94
  print(*args, **kwargs, flush = True)
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
class Session:
Ivan Mahonin b6b0ba
  def __init__(self, url, key, csr, answers_prefix, out_cert_file):
Ivan Mahonin b6b0ba
    self.url = str(url)
Ivan Mahonin 7a50dc
    self.key = key
Ivan Mahonin b6b0ba
    self.csr = csr
Ivan Mahonin b6b0ba
    self.answers_prefix = str(answers_prefix)
Ivan Mahonin b6b0ba
    self.out_cert_file = str(out_cert_file)
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
Ivan Mahonin 7bd19b
  # common method uses in many steps to send unsigned requests
Ivan Mahonin cc2683
  # returns: requests.Response
Ivan Mahonin 7bd19b
  def get_request(self, url):
Ivan Mahonin 882d94
    log('  get ' + url)
Ivan Mahonin 7bd19b
    r = requests.get(url)
Ivan Mahonin 7bd19b
    if r.status_code != 200:
Ivan Mahonin 882d94
      log('response status code: ' + str(r.status_code))
Ivan Mahonin 882d94
      log('response headers')
Ivan Mahonin 882d94
      log(r.headers)
Ivan Mahonin 882d94
      log('response body')
Ivan Mahonin 882d94
      log(r.text)
Ivan Mahonin cc2683
      raise Exception('unexpected server answer')
Ivan Mahonin cc2683
    return r
Ivan Mahonin cc2683
Ivan Mahonin cc2683
Ivan Mahonin 7bd19b
  # common method uses in many steps to send signed requests
Ivan Mahonin cc2683
  # in: self.nonce, self.key, self.kid
Ivan Mahonin cc2683
  # out: self.nonce
Ivan Mahonin cc2683
  # returns: requests.Response
Ivan Mahonin 7bd19b
  def post_signed_request(self, url, claims):
Ivan Mahonin 882d94
    log('  post signed ' + url)
Ivan Mahonin 882d94
Ivan Mahonin cc2683
    header = {
Ivan Mahonin cc2683
      'alg': 'RS256',
Ivan Mahonin cc2683
      'nonce': self.nonce,
Ivan Mahonin cc2683
      'url': url }
Ivan Mahonin cc2683
    if self.kid is None:
Ivan Mahonin cc2683
      header['jwk'] = json.loads(self.key.export_public())
Ivan Mahonin cc2683
    else:
Ivan Mahonin cc2683
      header['kid'] = self.kid
Ivan Mahonin 882d94
Ivan Mahonin cc2683
    signer = jws.JWS(json.dumps(claims))
Ivan Mahonin cc2683
    signer.add_signature(self.key, protected = json.dumps(header))
Ivan Mahonin cc2683
    data = signer.serialize()
Ivan Mahonin 882d94
Ivan Mahonin cc2683
    headers = { 'Content-Type': 'application/jose+json' }
Ivan Mahonin cc2683
    r = requests.post(url, headers = headers, data = data)
Ivan Mahonin 7bd19b
    if r.status_code != 200 and r.status_code != 201:
Ivan Mahonin 882d94
      log('response status code ' + str(r.status_code))
Ivan Mahonin 882d94
      log('response headers')
Ivan Mahonin 882d94
      log(r.headers)
Ivan Mahonin 882d94
      log('response body')
Ivan Mahonin 882d94
      log(r.text)
Ivan Mahonin cc2683
      raise Exception('unexpected server answer')
Ivan Mahonin cc2683
    self.nonce = str(r.headers['Replay-Nonce'])
Ivan Mahonin 882d94
    log('  nonce: ' + self.nonce)
Ivan Mahonin cc2683
    return r
Ivan Mahonin cc2683
Ivan Mahonin cc2683
Ivan Mahonin b6b0ba
  # step 1
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  # in: self.url
Ivan Mahonin b6b0ba
  # out: self.url_newnonce, self.url_newaccount, self.url_neworder
Ivan Mahonin 7a50dc
  def fetch_directory(self):
Ivan Mahonin 882d94
    log('fetch directory')
Ivan Mahonin 7bd19b
    r = self.get_request(self.url)
Ivan Mahonin 7a50dc
    json = r.json()
Ivan Mahonin 7a50dc
    self.url_newnonce = str(json['newNonce'])
Ivan Mahonin 7a50dc
    self.url_newaccount = str(json['newAccount'])
Ivan Mahonin 7a50dc
    self.url_neworder = str(json['newOrder'])
Ivan Mahonin 882d94
    log('  url for new nonce: ' + self.url_newnonce)
Ivan Mahonin 882d94
    log('  url for new account: ' + self.url_newaccount)
Ivan Mahonin 882d94
    log('  url for new order: ' + self.url_neworder)
Ivan Mahonin 882d94
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
  # step 2
Ivan Mahonin b6b0ba
  # in: self.url_newnonce
Ivan Mahonin b6b0ba
  # out: self.nonce
Ivan Mahonin 7a50dc
  def fetch_nonce(self):
Ivan Mahonin 882d94
    log('fetch nonce')
Ivan Mahonin 882d94
    log('  head ' + self.url_newnonce)
Ivan Mahonin b6b0ba
    r = requests.head(self.url_newnonce)
Ivan Mahonin 7a50dc
    if r.status_code != 200:
Ivan Mahonin 882d94
      log('response status code: ' + r.status_code)
Ivan Mahonin 882d94
      log('response headers')
Ivan Mahonin 882d94
      log(r.headers)
Ivan Mahonin 882d94
      log('response body')
Ivan Mahonin 882d94
      log(r.text)
Ivan Mahonin cc2683
      raise Exception('unexpected server answer')
Ivan Mahonin 7a50dc
    self.nonce = str(r.headers['Replay-Nonce'])
Ivan Mahonin 882d94
    log('  nonce: ' + self.nonce)
Ivan Mahonin 882d94
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
  # step 3
Ivan Mahonin 7bd19b
  # uses: post_signed_request (also see 'in' and 'out' there)
Ivan Mahonin b6b0ba
  # in: self.url_newaccount
Ivan Mahonin b6b0ba
  # out: self.kid
Ivan Mahonin b6b0ba
  def fetch_account(self):
Ivan Mahonin 882d94
    log('fetch account')
Ivan Mahonin b6b0ba
    self.kid = None
Ivan Mahonin b6b0ba
    claims = { 'termsOfServiceAgreed': True }
Ivan Mahonin 7bd19b
    r = self.post_signed_request(self.url_newaccount, claims)
Ivan Mahonin b6b0ba
    self.kid = str(r.headers['Location'])
Ivan Mahonin 882d94
    log('  kid: ' + self.kid)
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 4
Ivan Mahonin 7bd19b
  # uses: post_signed_request (also see 'in' and 'out' there)
Ivan Mahonin 7bd19b
  # in: self.csr, self.url_neworder
Ivan Mahonin cc2683
  # out: self.url_order, self.url_authorizations, self.url_finalize
Ivan Mahonin b6b0ba
  def create_order(self):
Ivan Mahonin 882d94
    log('create order')
Ivan Mahonin 7bd19b
Ivan Mahonin 7bd19b
    common_name = str( self.csr.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value )
Ivan Mahonin 882d94
    log('  csr common name: ' + common_name)
Ivan Mahonin 882d94
Ivan Mahonin 7bd19b
    names = { common_name }
Ivan Mahonin 7bd19b
    try:
Ivan Mahonin 7bd19b
      extension = self.csr.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
Ivan Mahonin 7bd19b
      for x in extension.value.get_values_for_type(x509.DNSName):
Ivan Mahonin 7bd19b
        names.add(str(x))
Ivan Mahonin 7bd19b
    except x509.ExtensionNotFound:
Ivan Mahonin 7bd19b
        pass
Ivan Mahonin 882d94
Ivan Mahonin 7bd19b
    identifiers = []
Ivan Mahonin 7bd19b
    for x in names:
Ivan Mahonin 7bd19b
      identifiers.append({
Ivan Mahonin 7bd19b
        'type': 'dns',
Ivan Mahonin 7bd19b
        'value': x })
Ivan Mahonin 882d94
      log('  csr name: ' + x)
Ivan Mahonin b6b0ba
    claims = { 'identifiers': identifiers }
Ivan Mahonin 882d94
Ivan Mahonin 7bd19b
    r = self.post_signed_request(self.url_neworder, claims)
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
    self.url_order = str(r.headers['Location'])
Ivan Mahonin 882d94
Ivan Mahonin 7a50dc
    json = r.json()
Ivan Mahonin b6b0ba
    self.url_authorizations = []
Ivan Mahonin cc2683
    for x in json['authorizations']:
Ivan Mahonin b6b0ba
      self.url_authorizations.append(str(x))
Ivan Mahonin 882d94
      log('  url for authorization: ' + str(x))
Ivan Mahonin b6b0ba
    self.url_finalize = str(json['finalize'])
Ivan Mahonin 882d94
    log('  url for finalize: ' + self.url_finalize)
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 5.1
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  # returns: url_chall (str), token (str)
Ivan Mahonin b6b0ba
  def fetch_authorization(self, url_authorization):
Ivan Mahonin 882d94
    log('fetch authorization')
Ivan Mahonin 7bd19b
    r = self.get_request(url_authorization)
Ivan Mahonin b6b0ba
    json = r.json()
Ivan Mahonin 882d94
    log('  identifier type: ' + str(json['identifier']['type']))
Ivan Mahonin 882d94
    log('  identifier value: ' + str(json['identifier']['value']))
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
    url_chall = None
Ivan Mahonin b6b0ba
    token = None
Ivan Mahonin cc2683
    for x in json['challenges']:
Ivan Mahonin b6b0ba
      challenge = str(x['type'])
Ivan Mahonin b6b0ba
      if challenge == 'http-01':
Ivan Mahonin 882d94
        log('  suitable challenge: ' + challenge)
Ivan Mahonin b6b0ba
        url_chall = str(x['url'])
Ivan Mahonin b6b0ba
        token = str(x['token'])
Ivan Mahonin b6b0ba
        break
Ivan Mahonin b6b0ba
    assert(not url_chall is None)
Ivan Mahonin b6b0ba
    assert(not token is None)
Ivan Mahonin b6b0ba
Ivan Mahonin 882d94
    log('  url for challenge: ' + url_chall)
Ivan Mahonin 882d94
    log('  url for challenge: ' + url_chall)
Ivan Mahonin 882d94
    log('  challenge token: ' + token)
Ivan Mahonin 882d94
Ivan Mahonin 882d94
    log('  verify token')
Ivan Mahonin b6b0ba
    if len(token) > 256:
Ivan Mahonin b6b0ba
        raise Exception('token too long, maximum length is 256')
Ivan Mahonin b6b0ba
    valid_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
Ivan Mahonin b6b0ba
    for char in token:
Ivan Mahonin b6b0ba
      if not char in valid_chars:
Ivan Mahonin b6b0ba
        raise Exception('wrong token, allowed following chars only: ' + valid_chars)
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
    return url_chall, token
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 5.2
Ivan Mahonin b6b0ba
  # in: self.answers_prefix
Ivan Mahonin 4e2d47
  # returns: temporary filename to remove after authorization
Ivan Mahonin b6b0ba
  def prepare_challenge_answer(self, token):
Ivan Mahonin 882d94
    log('prepare challenge answer')
Ivan Mahonin b6b0ba
    answer = token + '.' + str(key.thumbprint(jwk.hashes.SHA256()))
Ivan Mahonin 882d94
    log('  answer: ' + answer)
Ivan Mahonin b6b0ba
    filename = answers_prefix + token
Ivan Mahonin 882d94
    log('  write answer to file: ' + filename)
Ivan Mahonin b6b0ba
    with open(filename, 'w') as f:
Ivan Mahonin b6b0ba
      f.write( answer )
Ivan Mahonin 4e2d47
    return filename
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 5.3
Ivan Mahonin 7bd19b
  # uses: post_signed_request (also see 'in' and 'out' there)
Ivan Mahonin b6b0ba
  def notify_challenge_ready(self, url_chall):
Ivan Mahonin 882d94
    log('notify that challenge is ready')
Ivan Mahonin 7bd19b
    self.post_signed_request(url_chall, dict())
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 5.4
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  def wait_authorization(self, url_authorization):
Ivan Mahonin b6b0ba
    for i in range(0, 10):
Ivan Mahonin 882d94
      log('wait 5 seconds')
Ivan Mahonin b6b0ba
      time.sleep(5)
Ivan Mahonin 882d94
      log('check authorization')
Ivan Mahonin 7bd19b
      r = self.get_request(url_authorization)
Ivan Mahonin b6b0ba
      json = r.json()
Ivan Mahonin b6b0ba
      status = str(json['status'])
Ivan Mahonin 882d94
      log('  authorization status: ' + status)
Ivan Mahonin b6b0ba
      if status == 'valid':
Ivan Mahonin 882d94
        log('authorization success')
Ivan Mahonin b6b0ba
        return
Ivan Mahonin b6b0ba
      assert(status == 'pending')
Ivan Mahonin b6b0ba
    raise Exception('authorization was not happened')
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin 4e2d47
  # step 5.5
Ivan Mahonin 4e2d47
  # in: self.answers_prefix
Ivan Mahonin 4e2d47
  def remove_challenge_answer(self, filename):
Ivan Mahonin 4e2d47
    log('remove challenge answer file: ' + filename)
Ivan Mahonin 4e2d47
    os.remove(filename)
Ivan Mahonin 4e2d47
Ivan Mahonin 4e2d47
Ivan Mahonin b6b0ba
  # step 5 all
Ivan Mahonin 7bd19b
  # uses: get_request, post_signed_request (also see 'in' and 'out' there)
Ivan Mahonin b6b0ba
  # in: self.url_authorizations
Ivan Mahonin b6b0ba
  def process_authorizations(self):
Ivan Mahonin 882d94
    log('process authorizations')
Ivan Mahonin b6b0ba
    for url_authorization in self.url_authorizations:
Ivan Mahonin b6b0ba
      url_chall, token = self.fetch_authorization(url_authorization)
Ivan Mahonin 4e2d47
      tmpfile = self.prepare_challenge_answer(token)
Ivan Mahonin b6b0ba
      self.notify_challenge_ready(url_chall)
Ivan Mahonin b6b0ba
      self.wait_authorization(url_authorization)
Ivan Mahonin 4e2d47
      self.remove_challenge_answer(tmpfile)
Ivan Mahonin 882d94
    log('all authorizations success')
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 6
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  # in: self.url_order
Ivan Mahonin b6b0ba
  def wait_order_ready(self):
Ivan Mahonin 7a50dc
    for i in range(0, 10):
Ivan Mahonin 882d94
      log('wait 5 seconds')
Ivan Mahonin 7a50dc
      time.sleep(5)
Ivan Mahonin 882d94
      log('check order status')
Ivan Mahonin 7bd19b
      r = self.get_request(self.url_order)
Ivan Mahonin 7a50dc
      json = r.json()
Ivan Mahonin 7a50dc
      status = str(json['status'])
Ivan Mahonin 882d94
      log('  order status: ' + status)
Ivan Mahonin 7a50dc
      if status == 'ready':
Ivan Mahonin 882d94
        log('order is ready')
Ivan Mahonin b6b0ba
        return
Ivan Mahonin 7a50dc
      assert(status == 'pending')
Ivan Mahonin b6b0ba
    raise Exception('order was not became ready')
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 7
Ivan Mahonin 7bd19b
  # uses: post_signed_request (also see 'in' and 'out' there)
Ivan Mahonin b6b0ba
  # in: self.csr, self.url_finalize
Ivan Mahonin b6b0ba
  def finalize_order(self):
Ivan Mahonin 882d94
    log('finalize order (send CSR)')
Ivan Mahonin 7bd19b
    csr_data = base64url_encode(self.csr.public_bytes(Encoding.DER))
Ivan Mahonin 882d94
    log('  csr data: ' + csr_data)
Ivan Mahonin 7a50dc
    claims = { 'csr': csr_data }
Ivan Mahonin 7bd19b
    self.post_signed_request(self.url_finalize, claims)
Ivan Mahonin 882d94
Ivan Mahonin 882d94
Ivan Mahonin b6b0ba
  # step 8
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  # in: self.url_order
Ivan Mahonin cc2683
  # out: self.url_cert
Ivan Mahonin b6b0ba
  def wait_order_valid(self):
Ivan Mahonin 7a50dc
    for i in range(0, 10):
Ivan Mahonin 882d94
      log('wait 5 seconds')
Ivan Mahonin 7a50dc
      time.sleep(5)
Ivan Mahonin 882d94
      log('check order status')
Ivan Mahonin 7bd19b
      r = self.get_request(self.url_order)
Ivan Mahonin 7a50dc
      json = r.json()
Ivan Mahonin 7a50dc
      status = str(json['status'])
Ivan Mahonin 882d94
      log('  order status: ' + status)
Ivan Mahonin 7a50dc
      if status == 'valid':
Ivan Mahonin 7bd19b
        self.url_cert = str(json['certificate'])
Ivan Mahonin 882d94
        log('  url for cert: ' + self.url_cert)
Ivan Mahonin 882d94
        log('order success')
Ivan Mahonin b6b0ba
        return
Ivan Mahonin 7a50dc
      assert(status == 'processing')
Ivan Mahonin b6b0ba
    raise Exception('order was not became valid')
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # step 9
Ivan Mahonin 7bd19b
  # uses: get_request
Ivan Mahonin b6b0ba
  # in: self.url_cert
Ivan Mahonin b6b0ba
  def fetch_certificate(self):
Ivan Mahonin 882d94
    log('fetch certificate')
Ivan Mahonin 7bd19b
    r = self.get_request(self.url_cert)
Ivan Mahonin 7bd19b
    cert = r.text
Ivan Mahonin 882d94
Ivan Mahonin 7bd19b
    if len(cert) > 10000000:
Ivan Mahonin 7bd19b
      raise Exception('certificate too long, max length is 10000000')
Ivan Mahonin 882d94
Ivan Mahonin 882d94
    log('downloaded certificate:')
Ivan Mahonin 882d94
    log(cert)
Ivan Mahonin 882d94
    log('write certificate to file: ' + out_cert_file)
Ivan Mahonin 7a50dc
    with open(out_cert_file, 'w') as f:
Ivan Mahonin 7bd19b
      f.write(cert)
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
Ivan Mahonin b6b0ba
  # all steps
Ivan Mahonin b6b0ba
  def run(self):
Ivan Mahonin 882d94
    log('run')
Ivan Mahonin b6b0ba
    self.fetch_directory()
Ivan Mahonin b6b0ba
    self.fetch_nonce()
Ivan Mahonin b6b0ba
    self.fetch_account()
Ivan Mahonin b6b0ba
    self.create_order()
Ivan Mahonin b6b0ba
    self.process_authorizations()
Ivan Mahonin b6b0ba
    self.wait_order_ready()
Ivan Mahonin b6b0ba
    self.finalize_order()
Ivan Mahonin b6b0ba
    self.wait_order_valid()
Ivan Mahonin b6b0ba
    self.fetch_certificate()
Ivan Mahonin 882d94
    log('done')
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
Ivan Mahonin 7a50dc
Ivan Mahonin cc2683
if len(sys.argv) != 3 or (len(sys.argv) == 2 and sys.argv[1] == '--help'):
Ivan Mahonin 882d94
  log('Usage: ' + sys.argv[0] + ' /path/to/certificate/sign/request.csr /path/to/out/certificate.crt')
Ivan Mahonin cc2683
  quit()
Ivan Mahonin cc2683
Ivan Mahonin cc2683
Ivan Mahonin cc2683
csr_file = str(sys.argv[1])
Ivan Mahonin cc2683
out_cert_file = str(sys.argv[2])
Ivan Mahonin cc2683
Ivan Mahonin 882d94
log('hello')
Ivan Mahonin 7a50dc
Ivan Mahonin 882d94
log('load api client key from file: ' + api_client_key_file)
Ivan Mahonin 7a50dc
with open(api_client_key_file, 'r') as f:
Ivan Mahonin 7a50dc
  key = jwk.JWK( **json.loads(f.read()) )
Ivan Mahonin 882d94
log('  loaded fingerprint: ' + str(key.thumbprint()))
Ivan Mahonin b6b0ba
Ivan Mahonin 7a50dc
Ivan Mahonin 882d94
log('load CSF from file: ' + csr_file)
Ivan Mahonin b6b0ba
with open(csr_file, 'rb') as f:
Ivan Mahonin b6b0ba
  csr = x509.load_pem_x509_csr(f.read(), default_backend())
Ivan Mahonin 7a50dc
Ivan Mahonin b6b0ba
session = Session(api_url, key, csr, answers_prefix, out_cert_file)
Ivan Mahonin b6b0ba
session.run()
Ivan Mahonin 7a50dc