acme_tiny.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env python
  2. import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
  3. try:
  4. from urllib.request import urlopen # Python 3
  5. except ImportError:
  6. from urllib2 import urlopen # Python 2
  7. LOGGER = logging.getLogger(__name__)
  8. LOGGER.addHandler(logging.StreamHandler())
  9. LOGGER.setLevel(logging.INFO)
  10. def get_crt(account_key, csr, acme_dir, log=LOGGER, CA="https://acme-v01.api.letsencrypt.org"):
  11. # helper function base64 encode for jose spec
  12. def _b64(b):
  13. return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "")
  14. # parse account key to get public key
  15. log.info("Parsing account key...")
  16. proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"],
  17. stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  18. out, err = proc.communicate()
  19. if proc.returncode != 0:
  20. raise IOError("OpenSSL Error: {0}".format(err))
  21. pub_hex, pub_exp = re.search(
  22. r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
  23. out.decode('utf8'), re.MULTILINE|re.DOTALL).groups()
  24. pub_exp = "{0:x}".format(int(pub_exp))
  25. pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
  26. header = {
  27. "alg": "RS256",
  28. "jwk": {
  29. "e": _b64(binascii.unhexlify(pub_exp)),
  30. "kty": "RSA",
  31. "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex))),
  32. },
  33. }
  34. accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':'))
  35. thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
  36. # helper function make signed requests
  37. def _send_signed_request(url, payload):
  38. payload64 = _b64(json.dumps(payload).encode('utf8'))
  39. protected = copy.deepcopy(header)
  40. protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce']
  41. protected64 = _b64(json.dumps(protected).encode('utf8'))
  42. proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key],
  43. stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  44. out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8'))
  45. if proc.returncode != 0:
  46. raise IOError("OpenSSL Error: {0}".format(err))
  47. data = json.dumps({
  48. "header": header,
  49. "protected": protected64,
  50. "payload": payload64,
  51. "signature": _b64(out),
  52. })
  53. try:
  54. resp = urlopen(url, data.encode('utf8'))
  55. return resp.getcode(), resp.read()
  56. except IOError as e:
  57. return e.code, e.read()
  58. # find domains
  59. log.info("Parsing CSR...")
  60. proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"],
  61. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  62. out, err = proc.communicate()
  63. if proc.returncode != 0:
  64. raise IOError("Error loading {0}: {1}".format(csr, err))
  65. domains = set([])
  66. common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8'))
  67. if common_name is not None:
  68. domains.add(common_name.group(1))
  69. subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL)
  70. if subject_alt_names is not None:
  71. for san in subject_alt_names.group(1).split(", "):
  72. if san.startswith("DNS:"):
  73. domains.add(san[4:])
  74. # get the certificate domains and expiration
  75. log.info("Registering account...")
  76. code, result = _send_signed_request(CA + "/acme/new-reg", {
  77. "resource": "new-reg",
  78. "agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf",
  79. })
  80. if code == 201:
  81. log.info("Registered!")
  82. elif code == 409:
  83. log.info("Already registered!")
  84. else:
  85. raise ValueError("Error registering: {0} {1}".format(code, result))
  86. # verify each domain
  87. for domain in domains:
  88. log.info("Verifying {0}...".format(domain))
  89. # get new challenge
  90. code, result = _send_signed_request(CA + "/acme/new-authz", {
  91. "resource": "new-authz",
  92. "identifier": {"type": "dns", "value": domain},
  93. })
  94. if code != 201:
  95. raise ValueError("Error registering: {0} {1}".format(code, result))
  96. # make the challenge file
  97. challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0]
  98. token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
  99. keyauthorization = "{0}.{1}".format(token, thumbprint)
  100. wellknown_path = os.path.join(acme_dir, token)
  101. with open(wellknown_path, "w") as wellknown_file:
  102. wellknown_file.write(keyauthorization)
  103. # check that the file is in place
  104. wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token)
  105. try:
  106. resp = urlopen(wellknown_url)
  107. assert resp.read().decode('utf8').strip() == keyauthorization
  108. except (IOError, AssertionError):
  109. os.remove(wellknown_path)
  110. raise ValueError("Wrote file to {0}, but couldn't download {1}".format(
  111. wellknown_path, wellknown_url))
  112. # notify challenge are met
  113. code, result = _send_signed_request(challenge['uri'], {
  114. "resource": "challenge",
  115. "keyAuthorization": keyauthorization,
  116. })
  117. if code != 202:
  118. raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
  119. # wait for challenge to be verified
  120. while True:
  121. try:
  122. resp = urlopen(challenge['uri'])
  123. challenge_status = json.loads(resp.read().decode('utf8'))
  124. except IOError as e:
  125. raise ValueError("Error checking challenge: {0} {1}".format(
  126. e.code, json.loads(e.read().decode('utf8'))))
  127. if challenge_status['status'] == "pending":
  128. time.sleep(2)
  129. elif challenge_status['status'] == "valid":
  130. log.info("{0} verified!".format(domain))
  131. os.remove(wellknown_path)
  132. break
  133. else:
  134. raise ValueError("{0} challenge did not pass: {1}".format(
  135. domain, challenge_status))
  136. # get the new certificate
  137. log.info("Signing certificate...")
  138. proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"],
  139. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  140. csr_der, err = proc.communicate()
  141. code, result = _send_signed_request(CA + "/acme/new-cert", {
  142. "resource": "new-cert",
  143. "csr": _b64(csr_der),
  144. })
  145. if code != 201:
  146. raise ValueError("Error signing certificate: {0} {1}".format(code, result))
  147. # return signed certificate!
  148. log.info("Certificate signed!")
  149. return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
  150. "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64)))
  151. if __name__ == "__main__":
  152. parser = argparse.ArgumentParser(
  153. formatter_class=argparse.RawDescriptionHelpFormatter,
  154. description=textwrap.dedent("""\
  155. This script automates the process of getting a signed TLS certificate from
  156. Let's Encrypt using the ACME protocol. It will need to be run on your server
  157. and have access to your private account key, so PLEASE READ THROUGH IT! It's
  158. only ~200 lines, so it won't take long.
  159. ===Example Usage===
  160. python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt
  161. ===================
  162. ===Example Crontab Renewal (once per month)===
  163. 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed.crt 2>> /var/log/acme_tiny.log
  164. ==============================================
  165. """)
  166. )
  167. parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key")
  168. parser.add_argument("--csr", required=True, help="path to your certificate signing request")
  169. parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory")
  170. parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
  171. parser.add_argument("--ca", required=False, default="https://acme-v01.api.letsencrypt.org", help="ACME CA server to handle requests")
  172. args = parser.parse_args()
  173. LOGGER.setLevel(args.quiet or LOGGER.level)
  174. signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, LOGGER, CA=args.ca)
  175. sys.stdout.write(signed_crt)