acme_dns_tiny.py 14.2 KB
Newer Older
1
#!/usr/bin/env python3
2
import os, argparse, subprocess, json, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
3
import dns.resolver, dns.tsigkeyring, dns.update
4 5
from configparser import ConfigParser
from urllib.request import urlopen
6
from urllib.error import HTTPError
7

Adrien Dorsaz's avatar
Adrien Dorsaz committed
8
LOGGER = logging.getLogger('acme_dns_tiny')
9 10 11
LOGGER.addHandler(logging.StreamHandler())
LOGGER.setLevel(logging.INFO)

12
def get_crt(config, log=LOGGER):
Adrien Dorsaz's avatar
Adrien Dorsaz committed
13
    # helper function base64 encode as defined in acme spec
Daniel Roesler's avatar
Daniel Roesler committed
14
    def _b64(b):
Adrien Dorsaz's avatar
Adrien Dorsaz committed
15
        return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
16

17 18 19 20 21 22 23 24
    # helper function to run openssl command
    def _openssl(command, options, communicate=None):
        openssl = subprocess.Popen(["openssl", command] + options,
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = openssl.communicate(communicate)
        if openssl.returncode != 0:
            raise IOError("OpenSSL Error: {0}".format(err))
        return out
25

26
    # helper function to send DNS dynamic update messages
27
    def _update_dns(rrset, action):
28
        algorithm = dns.name.from_text("{0}".format(config["TSIGKeyring"]["Algorithm"].lower()))
29
        dns_update = dns.update.Update(config["DNS"]["zone"], keyring=keyring, keyalgorithm=algorithm)
30 31 32 33
        if action == "add":
            dns_update.add(rrset.name, rrset)
        elif action == "delete":
            dns_update.delete(rrset.name, rrset)
34
        resp = dns.query.tcp(dns_update, config["DNS"]["Host"], port=config.getint("DNS", "Port"))
35 36 37
        dns_update = None
        return resp

Adrien Dorsaz's avatar
Adrien Dorsaz committed
38
    # helper function to send signed requests
39
    def _send_signed_request(url, payload):
40
        nonlocal jws_nonce
41
        payload64 = _b64(json.dumps(payload).encode("utf8"))
42
        protected = copy.deepcopy(jws_header)
43 44
        protected["nonce"] = jws_nonce or urlopen(acme_config["newNonce"]).getheader("Replay-Nonce", None)
        protected["url"] = url
45
        if url == acme_config["newAccount"]:
46 47 48
            del protected["kid"]
        else:
            del protected["jwk"]
49
        protected64 = _b64(json.dumps(protected).encode("utf8"))
50 51
        signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
52
        data = json.dumps({
53
            "protected": protected64, "payload": payload64,"signature": _b64(signature)
54 55 56
        })
        try:
            resp = urlopen(url, data.encode("utf8"))
57
        except HTTPError as httperror:
58 59 60 61
            resp = httperror
        finally:
            jws_nonce = resp.getheader("Replay-Nonce", None)
            return resp.getcode(), resp.read(), resp.getheaders()
62

63 64
    # main code
    log.info("Read ACME directory.")
65 66
    directory = urlopen(config["acmednstiny"]["ACMEDirectory"])
    acme_config = json.loads(directory.read().decode("utf8"))
67
    terms_service = acme_config.get("meta", {}).get("termsOfService")
68

69
    log.info("Prepare DNS keyring and resolver.")
70
    keyring = dns.tsigkeyring.from_text({config["TSIGKeyring"]["KeyName"]: config["TSIGKeyring"]["KeyValue"]})
71 72
    resolver = dns.resolver.Resolver(configure=False)
    resolver.retry_servfail = True
73
    nameserver = []
74 75
    try:
        nameserver = [ipv4_rrset.to_text() for ipv4_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
76
        nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
77
    except dns.exception.DNSException as e:
78 79 80
        log.info("A and/or AAAA DNS resources not found for configured dns host: we will use either resource found if exists or directly the DNS Host configuration.")
    if not nameserver:
        nameserver = [config["DNS"]["Host"]]
81
    resolver.nameservers = nameserver
Daniel Roesler's avatar
Daniel Roesler committed
82

83
    log.info("Parsing account key looking for public key.")
84
    accountkey = _openssl("rsa", ["-in", config["acmednstiny"]["AccountKeyFile"], "-noout", "-text"])
Daniel Roesler's avatar
Daniel Roesler committed
85
    pub_hex, pub_exp = re.search(
86
        r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
87
        accountkey.decode("utf8"), re.MULTILINE | re.DOTALL).groups()
Daniel Roesler's avatar
Daniel Roesler committed
88
    pub_exp = "{0:x}".format(int(pub_exp))
89
    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
90
    jws_header = {
Daniel Roesler's avatar
Daniel Roesler committed
91 92
        "alg": "RS256",
        "jwk": {
93
            "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
94
            "kty": "RSA",
95
            "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
96
        },
97
        "kid": None,
Daniel Roesler's avatar
Daniel Roesler committed
98
    }
99
    accountkey_json = json.dumps(jws_header["jwk"], sort_keys=True, separators=(",", ":"))
100
    thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
101
    jws_nonce = None
Daniel Roesler's avatar
Daniel Roesler committed
102

103
    log.info("Parsing CSR looking for domains.")
104
    csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-noout", "-text"]).decode("utf8")
Daniel Roesler's avatar
Daniel Roesler committed
105
    domains = set([])
106
    common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", csr)
Daniel Roesler's avatar
Daniel Roesler committed
107 108
    if common_name is not None:
        domains.add(common_name.group(1))
109
    subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \r?\n +([^\r\n]+)\r?\n", csr, re.MULTILINE | re.DOTALL)
Daniel Roesler's avatar
Daniel Roesler committed
110 111 112 113 114
    if subject_alt_names is not None:
        for san in subject_alt_names.group(1).split(", "):
            if san.startswith("DNS:"):
                domains.add(san[4:])

115
    log.info("Registering ACME Account.")
116
    account_request = {}
117
    account_request["termsOfServiceAgreed"] = True
118 119
    account_request["contact"] = config["acmednstiny"].get("Contacts", "").split(';')
    if account_request["contact"] == "":
120
        del account_request["contact"]
121

122
    code, result, headers = _send_signed_request(acme_config["newAccount"], account_request)
123
    account_info = {}
Daniel Roesler's avatar
Daniel Roesler committed
124
    if code == 201:
125 126
        jws_header["kid"] = dict(headers).get("Location")
        log.info("Registered! (account: '{0}')".format(jws_header["kid"]))
127 128
        account_info["termsOfServiceAgreed"] = True
        account_info["contact"] = account_request["contact"]
129
    elif code == 200:
130 131
        jws_header["kid"] = dict(headers).get("Location")
        log.info("Already registered! (account: '{0}')".format(jws_header["kid"]))
132

133
        code, result, headers = _send_signed_request(jws_header["kid"], {})
134
        account_info = json.loads(result.decode("utf8"))
Daniel Roesler's avatar
Daniel Roesler committed
135
    else:
136
        raise ValueError("Error registering: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
137

138
    log.info("Update contact information if needed.")
139 140
    if (set(account_request["contact"]) != set(account_info["contact"])):
        code, result, headers = _send_signed_request(jws_header["kid"], account_request)
141 142
        if code == 200:
            log.info("Account updated with latest contact informations.")
143 144 145
        else:
            raise ValueError("Error register update: {0} {1}".format(code, result))

146 147
    # new order
    log.info("Certification issuance: ask for a new Order")
148
    new_order = { "identifiers": [{"type": "dns", "value": domain} for domain in domains]}
149 150 151 152
    code, result, headers = _send_signed_request(acme_config["newOrder"], new_order)
    order = json.loads(result.decode("utf8"))
    if code == 201:
        order_location = dict(headers).get("Location")
153
        log.info("Order created: {0}".format(order_location))
154 155
    elif (code == 403
        and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
156
        raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], dict(headers)["Link"], order["instance"]))
157 158
    else:
        raise ValueError("Error getting new Order: {0} {1}".format(code, result))
159

160 161
    # complete each authorization challenge
    for authz in order["authorizations"]:
162
        log.info("Completing authz: {0}".format(authz))
Daniel Roesler's avatar
Daniel Roesler committed
163 164

        # get new challenge
165 166 167 168 169
        resp = urlopen(authz)
        authorization = json.loads(resp.read().decode("utf8"))
        if resp.getcode() != 200:
            raise ValueError("Error requesting challenges: {0} {1}".format(resp.getcode(), authorization))
        domain = authorization["identifier"]["value"]
Daniel Roesler's avatar
Daniel Roesler committed
170

171 172
        log.info("Create and install DNS TXT challenge resource for: {0}".format(domain))
        challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
173
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
174
        keyauthorization = "{0}.{1}".format(token, thumbprint)
175 176 177
        keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
        dnsrr_domain = "_acme-challenge.{0}.".format(domain)
        dnsrr_set = dns.rrset.from_text(dnsrr_domain, 300, "IN", "TXT",  '"{0}"'.format(keydigest64))
Daniel Roesler's avatar
Daniel Roesler committed
178
        try:
179
            _update_dns(dnsrr_set, "add")
180
        except dns.exception.DNSException as dnsexception:
181
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
182

183
        log.info("Wait {0} then start self challenge checks.".format(config["acmednstiny"].getint("CheckChallengeDelay")))
184
        time.sleep(config["acmednstiny"].getint("CheckChallengeDelay"))
185
        challenge_verified = False
186
        number_check_fail = 1
187 188
        while challenge_verified is False:
            try:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
189
                log.info('Try {0}: Check ressource with value "{1}" exits on nameservers: {2}'.format(number_check_fail, keydigest64, resolver.nameservers))
190 191
                challenges = resolver.query(dnsrr_domain, rdtype="TXT")
                for response in challenges.rrset:
192
                    log.info(".. Found value {0}".format(response.to_text()))
193 194 195 196 197
                    challenge_verified = challenge_verified or response.to_text() == '"{0}"'.format(keydigest64)
            except dns.exception.DNSException as dnsexception:
                log.info("Info: retry, because a DNS error occurred while checking challenge: {0} : {1}".format(type(dnsexception).__name__, dnsexception))
            finally:
                if challenge_verified is False:
198 199
                    if number_check_fail >= 10:
                        raise ValueError("Error checking challenge, value not found: {0}".format(keydigest64))
200 201
                    number_check_fail = number_check_fail + 1
                    time.sleep(2)
202

203
        log.info("Ask ACME server to perform checks.")
204 205
        code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
        if code != 200:
206
            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
207

208
        log.info("Waiting challenge to be verified.")
209 210 211
        try:
            while True:
                try:
212
                    resp = urlopen(challenge["url"])
213 214 215 216 217 218 219
                    challenge_status = json.loads(resp.read().decode("utf8"))
                except IOError as e:
                    raise ValueError("Error checking challenge: {0} {1}".format(
                        e.code, json.loads(e.read().decode("utf8"))))
                if challenge_status["status"] == "pending":
                    time.sleep(2)
                elif challenge_status["status"] == "valid":
220
                    log.info("Domain {0} verified!".format(domain))
221 222 223 224 225 226
                    break
                else:
                    raise ValueError("{0} challenge did not pass: {1}".format(
                        domain, challenge_status))
        finally:
            _update_dns(dnsrr_set, "delete")
Daniel Roesler's avatar
Daniel Roesler committed
227

228
    log.info("Finalizing the order...")
229 230 231
    resp = urlopen(order_location)
    finalize = json.loads(resp.read().decode("utf8"))
    log.info("Before sending request, order is: {0}".format(finalize))
232 233
    csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
    code, result, headers = _send_signed_request(order["finalize"], {"csr": csr_der})
234
    finalize = json.loads(result.decode("utf8"))
235

236 237
    while True:
        if finalize["status"] == "processing":
Adrien Dorsaz's avatar
Adrien Dorsaz committed
238
            time.sleep(resp.getheader("Retry-After", 2))
239 240 241 242 243 244
        elif finalize["status"] == "valid":
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
                domain, finalize))
245 246 247 248 249 250
        try:
            resp = urlopen(order_location)
            finalize = json.loads(resp.read().decode("utf8"))
        except IOError as e:
            raise ValueError("Error finalizing order: {0} {1}".format(
                e.code, json.loads(e.read().decode("utf8"))))
251
    
252
    resp = urlopen(finalize["certificate"])
Adrien Dorsaz's avatar
Adrien Dorsaz committed
253
    if resp.getcode() != 200:
254 255
        raise ValueError("Finalizing order {0} got errors: {1}".format(
            resp.getcode(), resp.read.decode("utf8")))
Adrien Dorsaz's avatar
Adrien Dorsaz committed
256
    certchain = resp.read().decode("utf8")
257
    
258
    log.info("Certificate signed and chain received: {0}".format(finalize["certificate"]))
Adrien Dorsaz's avatar
Adrien Dorsaz committed
259
    return certchain
Daniel Roesler's avatar
Daniel Roesler committed
260

Daniel Roesler's avatar
Daniel Roesler committed
261
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
262 263
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
264 265 266 267 268 269
        description="""
This script automates the process of getting a signed TLS certificate
chain from Let's Encrypt using the ACME protocol and its DNS verification.
It will need to have access to your private account key and dns server
so PLEASE READ THROUGH IT!
It's around 300 lines, so it won't take long.
jomo's avatar
jomo committed
270

271 272 273 274 275
===Example Usage===
python3 acme_dns_tiny.py ./example.ini > chain.crt
See example.ini file to configure correctly this script.
===================
"""
jomo's avatar
jomo committed
276
    )
277
    parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
278
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
279
    args = parser.parse_args(argv)
280 281

    config = ConfigParser()
282
    config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory",
283
                                      "CheckChallengeDelay": 2},
284
                      "DNS": {"Port": "53"}})
285 286
    config.read(args.configfile)

287
    if (set(["accountkeyfile", "csrfile", "acmedirectory", "checkchallengedelay"]) - set(config.options("acmednstiny"))
288
        or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
289
        or set(["zone", "host", "port"]) - set(config.options("DNS"))):
290 291
        raise ValueError("Some required settings are missing.")

292
    LOGGER.setLevel(args.quiet or LOGGER.level)
293
    signed_crt = get_crt(config, log=LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
294
    sys.stdout.write(signed_crt)
Daniel Roesler's avatar
Daniel Roesler committed
295

296
if __name__ == "__main__":  # pragma: no cover
Daniel Roesler's avatar
Daniel Roesler committed
297
    main(sys.argv[1:])