acme_dns_tiny.py 14.3 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
        protected["nonce"] = jws_nonce or urlopen(config["acmednstiny"]["ACMEDirectory"]).getheader("Replay-Nonce", None)
44
        protected64 = _b64(json.dumps(protected).encode("utf8"))
45
46
        signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
47
        data = json.dumps({
48
            "header": jws_header, "protected": protected64,
49
            "payload": payload64, "signature": _b64(signature),
50
51
52
        })
        try:
            resp = urlopen(url, data.encode("utf8"))
53
        except HTTPError as httperror:
54
55
56
57
            resp = httperror
        finally:
            jws_nonce = resp.getheader("Replay-Nonce", None)
            return resp.getcode(), resp.read(), resp.getheaders()
58

59
60
    # helper function to get url from Link HTTP headers
    def _get_url_link(headers, rel):
Adrien Dorsaz's avatar
Adrien Dorsaz committed
61
        log.info("Looking for Link with rel='{0}' in headers".format(rel))
62
63
64
65
66
67
68
        linkheaders = [link.strip() for link in dict(headers)["Link"].split(',')]
        url = [re.match(r'<(?P<url>.*)>.*;rel=(' + re.escape(rel) + r'|("([a-z][a-z0-9\.\-]*\s+)*' + re.escape(rel) + r'[\s"]))', link).groupdict()
                        for link in linkheaders][0]["url"]
        return url

    # main code
    log.info("Read ACME directory.")
69
70
    directory = urlopen(config["acmednstiny"]["ACMEDirectory"])
    acme_config = json.loads(directory.read().decode("utf8"))
71
    current_terms = acme_config.get("meta", {}).get("terms-of-service")
72

73
    log.info("Prepare DNS keyring and resolver.")
74
    keyring = dns.tsigkeyring.from_text({config["TSIGKeyring"]["KeyName"]: config["TSIGKeyring"]["KeyValue"]})
75
76
    resolver = dns.resolver.Resolver(configure=False)
    resolver.retry_servfail = True
77
    nameserver = []
78
79
    try:
        nameserver = [ipv4_rrset.to_text() for ipv4_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
80
        nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
81
    except dns.exception.DNSException as e:
82
83
84
        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"]]
85
    resolver.nameservers = nameserver
Daniel Roesler's avatar
Daniel Roesler committed
86

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

106
    log.info("Parsing CSR looking for domains.")
107
    csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-noout", "-text"]).decode("utf8")
Daniel Roesler's avatar
Daniel Roesler committed
108
    domains = set([])
109
    common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", csr)
Daniel Roesler's avatar
Daniel Roesler committed
110
111
    if common_name is not None:
        domains.add(common_name.group(1))
112
    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
113
114
115
116
117
    if subject_alt_names is not None:
        for san in subject_alt_names.group(1).split(", "):
            if san.startswith("DNS:"):
                domains.add(san[4:])

118
119
120
121
122
123
124
125
126
127
128
129
130
    log.info("Registering ACME Account.")
    reg_info = {"resource": "new-reg"}
    if current_terms is not None:
        reg_info["agreement"] = current_terms
    reg_info["contact"] = []
    reg_mailto = "mailto:{0}".format(config["acmednstiny"].get("MailContact"))
    reg_phone = "tel:{0}".format(config["acmednstiny"].get("PhoneContact"))
    if config["acmednstiny"].get("MailContact") is not None:
        reg_info["contact"].append(reg_mailto)
    if config["acmednstiny"].get("PhoneContact") is not None:
        reg_info["contact"].append(reg_phone)
    if len(reg_info["contact"]) == 0:
        del reg_info["contact"]
131

132
    code, result, headers = _send_signed_request(acme_config["new-reg"], reg_info)
Daniel Roesler's avatar
Daniel Roesler committed
133
    if code == 201:
134
        account_url = dict(headers).get("Location")
135
        log.info("Registered! (account: '{0}')".format(account_url))
136
137
138
        reg_received_contact = reg_info.get("contact")
    elif code == 409:
        account_url = dict(headers).get("Location")
139
        log.info("Already registered! (account: '{0}')".format(account_url))
140
141
142
143
144
        # Client should send empty payload to query account information
        code, result, headers = _send_signed_request(account_url, {"resource":"reg"})
        account_info = json.loads(result.decode("utf8"))
        reg_info["agreement"] = account_info.get("agreement")
        reg_received_contact = account_info.get("contact")
Daniel Roesler's avatar
Daniel Roesler committed
145
    else:
146
        raise ValueError("Error registering: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
147

148
149
150
151
    log.info("Update contact information and terms of service agreement if needed.")
    if current_terms is None:
        current_terms = _get_url_link(headers, 'terms-of-service')
    if (reg_info.get("agreement") != current_terms
152
153
        or reg_mailto not in reg_received_contact
        or reg_phone not in reg_received_contact):
154
155
156
157
158
159
160
161
        reg_info["resource"] = "reg"
        reg_info["agreement"] = current_terms
        code, result, headers = _send_signed_request(account_url, reg_info)
        if code == 202:
            log.info("Account updated (terms of service agreed: '{0}')".format(reg_info.get("agreement")))
        else:
            raise ValueError("Error register update: {0} {1}".format(code, result))

Daniel Roesler's avatar
Daniel Roesler committed
162
163
    # verify each domain
    for domain in domains:
164
        log.info("Verifying domain: {0}".format(domain))
Daniel Roesler's avatar
Daniel Roesler committed
165
166

        # get new challenge
167
        code, result, headers = _send_signed_request(acme_config["new-authz"], {
Daniel Roesler's avatar
Daniel Roesler committed
168
            "resource": "new-authz",
Collin Anderson's avatar
Collin Anderson committed
169
            "identifier": {"type": "dns", "value": domain},
Daniel Roesler's avatar
Daniel Roesler committed
170
171
        })
        if code != 201:
Daniel Roesler's avatar
Daniel Roesler committed
172
            raise ValueError("Error requesting challenges: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
173

174
        log.info("Create and install DNS TXT challenge resource.")
175
176
        challenge = [c for c in json.loads(result.decode("utf8"))["challenges"] if c["type"] == "dns-01"][0]
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
177
        keyauthorization = "{0}.{1}".format(token, thumbprint)
178
179
180
        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
181
        try:
182
            _update_dns(dnsrr_set, "add")
183
        except dns.exception.DNSException as dnsexception:
184
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
185

186
        log.info("Wait {0} then start self challenge checks.".format(config["acmednstiny"].getint("CheckChallengeDelay")))
187
        time.sleep(config["acmednstiny"].getint("CheckChallengeDelay"))
188
        challenge_verified = False
189
        number_check_fail = 1
190
191
        while challenge_verified is False:
            try:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
192
                log.info('Try {0}: Check ressource with value "{1}" exits on nameservers: {2}'.format(number_check_fail, keydigest64, resolver.nameservers))
193
194
                challenges = resolver.query(dnsrr_domain, rdtype="TXT")
                for response in challenges.rrset:
195
                    log.info(".. Found value {0}".format(response.to_text()))
196
197
198
199
                    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:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
200
                if number_check_fail >= 10:
201
                    raise ValueError("Error checking challenge, value not found: {0}".format(keydigest64))
202

203
204
205
                if challenge_verified is False:
                    number_check_fail = number_check_fail + 1
                    time.sleep(2)
206

207
        log.info("Ask ACME server to perform checks.")
208
        code, result, headers = _send_signed_request(challenge["uri"], {
Daniel Roesler's avatar
Daniel Roesler committed
209
210
211
212
            "resource": "challenge",
            "keyAuthorization": keyauthorization,
        })
        if code != 202:
213
            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
214

215
        log.info("Waiting challenge to be verified.")
216
217
218
219
220
221
222
223
224
225
226
        try:
            while True:
                try:
                    resp = urlopen(challenge["uri"])
                    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":
227
                    log.info("Domain {0} verified!".format(domain))
228
229
230
231
232
233
                    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
234

235
    log.info("Ask to sign certificate.")
236
    csr_der = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"])
237
    code, result, headers = _send_signed_request(acme_config["new-cert"], {
Daniel Roesler's avatar
Daniel Roesler committed
238
239
240
241
        "resource": "new-cert",
        "csr": _b64(csr_der),
    })
    if code != 201:
242
        raise ValueError("Error signing certificate: {0} {1}".format(code, result))
243
    certificate = os.linesep.join(textwrap.wrap(base64.b64encode(result).decode("utf8"), 64))
244
245

    # get the parent certificate which had created this one
246
    certificate_parent_url = _get_url_link(headers, 'up')
247
    resp = urlopen(certificate_parent_url)
248
    if resp.getcode() not in [200, 201]:
249
        raise ValueError("Error getting certificate chain from {0}: {1} {2}".format(
250
            certificate_parent_url, code, resp.read()))
251
252
253
254
255
    intermediary_certificate = os.linesep.join(textwrap.wrap(base64.b64encode(resp.read()).decode("utf8"), 64))
    
    chainlist = ["-----BEGIN CERTIFICATE-----{0}{1}{0}-----END CERTIFICATE-----{0}".format(
        os.linesep, cert) for cert in [certificate, intermediary_certificate]]
    
256
    log.info("Certificate signed and received.")
257
    return "".join(chainlist)
Daniel Roesler's avatar
Daniel Roesler committed
258

Daniel Roesler's avatar
Daniel Roesler committed
259
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
260
261
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
262
263
264
265
266
267
        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
268

269
270
271
272
273
===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
274
    )
275
    parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
276
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
277
    args = parser.parse_args(argv)
278
279

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

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

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

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