Commit | Line | Data |
---|---|---|
61d08fc2 FT |
1 | #!/usr/bin/python3 |
2 | ||
f1b49ff6 FT |
3 | #### ACME client (only http-01 challenges supported thus far) |
4 | ||
28d5a321 | 5 | import sys, os, getopt, binascii, json, pprint, signal, time, threading |
61d08fc2 FT |
6 | import urllib.request |
7 | import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5 | |
8 | ||
f1b49ff6 FT |
9 | ### General utilities |
10 | ||
28d5a321 FT |
11 | class msgerror(Exception): |
12 | def report(self, out): | |
13 | out.write("acmecert: undefined error\n") | |
14 | ||
61d08fc2 FT |
15 | def base64url(dat): |
16 | return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip() | |
17 | ||
18 | def ebignum(num): | |
19 | h = "%x" % num | |
20 | if len(h) % 2 == 1: h = "0" + h | |
21 | return base64url(binascii.a2b_hex(h)) | |
22 | ||
f1b49ff6 FT |
23 | class maybeopen(object): |
24 | def __init__(self, name, mode): | |
25 | if name == "-": | |
26 | self.opened = False | |
27 | if mode == "r": | |
28 | self.fp = sys.stdin | |
29 | elif mode == "w": | |
30 | self.fp = sys.stdout | |
31 | else: | |
32 | raise ValueError(mode) | |
0305dfdc | 33 | else: |
f1b49ff6 FT |
34 | self.opened = True |
35 | self.fp = open(name, mode) | |
0305dfdc | 36 | |
f1b49ff6 FT |
37 | def __enter__(self): |
38 | return self.fp | |
0305dfdc | 39 | |
f1b49ff6 FT |
40 | def __exit__(self, *excinfo): |
41 | if self.opened: | |
42 | self.fp.close() | |
43 | return False | |
44 | ||
45 | ### Crypto utilities | |
61d08fc2 | 46 | |
14a46eff FT |
47 | class certificate(object): |
48 | @property | |
49 | def enddate(self): | |
50 | # No X509 parser for Python? | |
51 | import subprocess, re, calendar | |
52 | with subprocess.Popen(["openssl", "x509", "-noout", "-enddate"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
53 | openssl.stdin.write(self.data.encode("us-ascii")) | |
54 | openssl.stdin.close() | |
55 | resp = openssl.stdout.read().decode("utf-8") | |
56 | if openssl.wait() != 0: | |
57 | raise Exception("openssl error") | |
58 | m = re.search(r"notAfter=(.*)$", resp) | |
59 | if m is None: raise Exception("unexpected openssl reply: %r" % (resp,)) | |
60 | return calendar.timegm(time.strptime(m.group(1), "%b %d %H:%M:%S %Y GMT")) | |
61 | ||
62 | def expiring(self, timespec): | |
63 | if timespec.endswith("y"): | |
64 | timespec = int(timespec[:-1]) * 365 * 86400 | |
65 | elif timespec.endswith("m"): | |
66 | timespec = int(timespec[:-1]) * 30 * 86400 | |
67 | elif timespec.endswith("w"): | |
68 | timespec = int(timespec[:-1]) * 7 * 86400 | |
69 | elif timespec.endswith("d"): | |
70 | timespec = int(timespec[:-1]) * 86400 | |
71 | elif timespec.endswith("h"): | |
72 | timespec = int(timespec[:-1]) * 3600 | |
73 | else: | |
74 | timespec = int(timespec) | |
75 | return (self.enddate - time.time()) < timespec | |
76 | ||
77 | @classmethod | |
78 | def read(cls, fp): | |
79 | self = cls() | |
80 | self.data = fp.read() | |
81 | return self | |
82 | ||
61d08fc2 FT |
83 | class signreq(object): |
84 | def domains(self): | |
85 | # No PCKS10 parser for Python? | |
86 | import subprocess, re | |
87 | with subprocess.Popen(["openssl", "req", "-noout", "-text"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
88 | openssl.stdin.write(self.data.encode("us-ascii")) | |
89 | openssl.stdin.close() | |
14a46eff | 90 | resp = openssl.stdout.read().decode("utf-8") |
61d08fc2 FT |
91 | if openssl.wait() != 0: |
92 | raise Exception("openssl error") | |
93 | m = re.search(r"X509v3 Subject Alternative Name:[^\n]*\n\s*((\w+:\S+,\s*)*\w+:\S+)\s*\n", resp) | |
94 | if m is None: | |
95 | return [] | |
96 | ret = [] | |
97 | for nm in m.group(1).split(","): | |
98 | nm = nm.strip() | |
99 | typ, nm = nm.split(":", 1) | |
100 | if typ == "DNS": | |
101 | ret.append(nm) | |
102 | return ret | |
103 | ||
104 | def der(self): | |
105 | import subprocess | |
106 | with subprocess.Popen(["openssl", "req", "-outform", "der"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
107 | openssl.stdin.write(self.data.encode("us-ascii")) | |
108 | openssl.stdin.close() | |
109 | resp = openssl.stdout.read() | |
110 | if openssl.wait() != 0: | |
111 | raise Exception("openssl error") | |
112 | return resp | |
113 | ||
114 | @classmethod | |
115 | def read(cls, fp): | |
116 | self = cls() | |
117 | self.data = fp.read() | |
118 | return self | |
119 | ||
f1b49ff6 FT |
120 | ### Somewhat general request utilities |
121 | ||
122 | def getnonce(): | |
123 | with urllib.request.urlopen(directory()["newNonce"]) as resp: | |
124 | resp.read() | |
125 | return resp.headers["Replay-Nonce"] | |
126 | ||
127 | def req(url, data=None, ctype=None, headers={}, method=None, **kws): | |
128 | if data is not None and not isinstance(data, bytes): | |
129 | data = json.dumps(data).encode("utf-8") | |
130 | ctype = "application/jose+json" | |
131 | req = urllib.request.Request(url, data=data, method=method) | |
132 | for hnam, hval in headers.items(): | |
133 | req.add_header(hnam, hval) | |
134 | if ctype is not None: | |
135 | req.add_header("Content-Type", ctype) | |
136 | return urllib.request.urlopen(req) | |
137 | ||
138 | class problem(msgerror): | |
139 | def __init__(self, code, data, *args, url=None, **kw): | |
140 | super().__init__(*args, **kw) | |
141 | self.code = code | |
142 | self.data = data | |
143 | self.url = url | |
144 | if not isinstance(data, dict): | |
145 | raise ValueError("unexpected problem object type: %r" % (data,)) | |
146 | ||
147 | @property | |
148 | def type(self): | |
149 | return self.data.get("type", "about:blank") | |
150 | @property | |
151 | def title(self): | |
152 | return self.data.get("title") | |
153 | @property | |
154 | def detail(self): | |
155 | return self.data.get("detail") | |
156 | ||
157 | def report(self, out): | |
158 | extra = None | |
159 | if self.title is None: | |
160 | msg = self.detail | |
161 | if "\n" in msg: | |
162 | extra, msg = msg, None | |
163 | else: | |
164 | msg = self.title | |
165 | extra = self.detail | |
166 | if msg is None: | |
167 | msg = self.data.get("type") | |
168 | if msg is not None: | |
169 | out.write("acemcert: %s: %s\n" % ( | |
170 | ("remote service error" if self.url is None else self.url), | |
171 | ("unspecified error" if msg is None else msg))) | |
172 | if extra is not None: | |
173 | out.write("%s\n" % (extra,)) | |
174 | ||
175 | @classmethod | |
176 | def read(cls, err, **kw): | |
177 | self = cls(err.code, json.load(err), **kw) | |
178 | return self | |
179 | ||
180 | def jreq(url, data, auth): | |
181 | authdata = {"alg": "RS256", "url": url, "nonce": getnonce()} | |
182 | authdata.update(auth.authdata()) | |
183 | authdata = base64url(json.dumps(authdata).encode("us-ascii")) | |
184 | if data is None: | |
185 | data = "" | |
186 | else: | |
187 | data = base64url(json.dumps(data).encode("us-ascii")) | |
188 | seal = base64url(auth.sign(("%s.%s" % (authdata, data)).encode("us-ascii"))) | |
189 | enc = {"protected": authdata, "payload": data, "signature": seal} | |
190 | try: | |
191 | with req(url, data=enc) as resp: | |
192 | return json.load(resp), resp.headers | |
193 | except urllib.error.HTTPError as exc: | |
194 | if exc.headers["Content-Type"] == "application/problem+json": | |
195 | raise problem.read(exc, url=url) | |
196 | raise | |
197 | ||
198 | ## Authentication | |
199 | ||
61d08fc2 FT |
200 | class jwkauth(object): |
201 | def __init__(self, key): | |
202 | self.key = key | |
203 | ||
204 | def authdata(self): | |
205 | return {"jwk": {"kty": "RSA", "e": ebignum(self.key.e), "n": ebignum(self.key.n)}} | |
206 | ||
207 | def sign(self, data): | |
208 | dig = Crypto.Hash.SHA256.new() | |
209 | dig.update(data) | |
210 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
211 | ||
212 | class account(object): | |
213 | def __init__(self, uri, key): | |
214 | self.uri = uri | |
215 | self.key = key | |
216 | ||
217 | def authdata(self): | |
218 | return {"kid": self.uri} | |
219 | ||
220 | def sign(self, data): | |
221 | dig = Crypto.Hash.SHA256.new() | |
222 | dig.update(data) | |
223 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
224 | ||
225 | def getinfo(self): | |
226 | data, headers = jreq(self.uri, None, self) | |
227 | return data | |
228 | ||
229 | def validate(self): | |
230 | data = self.getinfo() | |
231 | if data.get("status", "") != "valid": | |
232 | raise Exception("account is not valid: %s" % (data.get("status", "\"\""))) | |
233 | ||
234 | def write(self, out): | |
235 | out.write("%s\n" % (self.uri,)) | |
236 | out.write("%s\n" % (self.key.exportKey().decode("us-ascii"),)) | |
237 | ||
238 | @classmethod | |
239 | def read(cls, fp): | |
240 | uri = fp.readline() | |
241 | if uri == "": | |
242 | raise Exception("missing account URI") | |
243 | uri = uri.strip() | |
244 | key = Crypto.PublicKey.RSA.importKey(fp.read()) | |
245 | return cls(uri, key) | |
246 | ||
f1b49ff6 | 247 | ### ACME protocol |
61d08fc2 | 248 | |
f1b49ff6 FT |
249 | service = "https://acme-v02.api.letsencrypt.org/directory" |
250 | _directory = None | |
251 | def directory(): | |
252 | global _directory | |
253 | if _directory is None: | |
254 | with req(service) as resp: | |
255 | _directory = json.load(resp) | |
256 | return _directory | |
61d08fc2 FT |
257 | |
258 | def register(keysize=4096): | |
259 | key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read) | |
61d08fc2 FT |
260 | data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key)) |
261 | return account(headers["Location"], key) | |
262 | ||
263 | def mkorder(acct, csr): | |
264 | data, headers = jreq(directory()["newOrder"], {"identifiers": [{"type": "dns", "value": dn} for dn in csr.domains()]}, acct) | |
265 | data["acmecert.location"] = headers["Location"] | |
266 | return data | |
267 | ||
268 | def httptoken(acct, ch): | |
269 | jwk = {"kty": "RSA", "e": ebignum(acct.key.e), "n": ebignum(acct.key.n)} | |
270 | dig = Crypto.Hash.SHA256.new() | |
271 | dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii")) | |
272 | khash = base64url(dig.digest()) | |
273 | return ch["token"], ("%s.%s" % (ch["token"], khash)) | |
274 | ||
f1b49ff6 FT |
275 | def finalize(acct, csr, orderid): |
276 | order, headers = jreq(orderid, None, acct) | |
277 | if order["status"] == "valid": | |
278 | pass | |
279 | elif order["status"] == "ready": | |
280 | jreq(order["finalize"], {"csr": base64url(csr.der())}, acct) | |
281 | for n in range(30): | |
282 | resp, headers = jreq(orderid, None, acct) | |
283 | if resp["status"] == "processing": | |
284 | time.sleep(2) | |
285 | elif resp["status"] == "valid": | |
286 | order = resp | |
287 | break | |
288 | else: | |
289 | raise Exception("unexpected order status when finalizing: %s" % resp["status"]) | |
290 | else: | |
291 | raise Exception("order finalization timed out") | |
292 | else: | |
293 | raise Exception("unexpected order state when finalizing: %s" % (order["status"],)) | |
294 | with req(order["certificate"]) as resp: | |
295 | return resp.read().decode("us-ascii") | |
296 | ||
297 | ## http-01 challenge | |
298 | ||
299 | class htconfig(object): | |
300 | def __init__(self): | |
301 | self.roots = {} | |
302 | ||
303 | @classmethod | |
304 | def read(cls, fp): | |
305 | self = cls() | |
306 | for ln in fp: | |
307 | words = ln.split() | |
308 | if len(words) < 1 or ln[0] == '#': | |
309 | continue | |
310 | if words[0] == "root": | |
311 | self.roots[words[1]] = words[2] | |
312 | else: | |
313 | sys.stderr.write("acmecert: warning: unknown htconfig directive: %s\n" % (words[0])) | |
314 | return self | |
315 | ||
61d08fc2 FT |
316 | def authorder(acct, htconf, orderid): |
317 | order, headers = jreq(orderid, None, acct) | |
318 | valid = False | |
319 | tries = 0 | |
320 | while not valid: | |
321 | valid = True | |
322 | tries += 1 | |
323 | if tries > 5: | |
324 | raise Exception("challenges refuse to become valid even after 5 retries") | |
325 | for authuri in order["authorizations"]: | |
326 | auth, headers = jreq(authuri, None, acct) | |
327 | if auth["status"] == "valid": | |
328 | continue | |
329 | elif auth["status"] == "pending": | |
330 | pass | |
331 | else: | |
332 | raise Exception("unknown authorization status: %s" % (auth["status"],)) | |
333 | valid = False | |
334 | if auth["identifier"]["type"] != "dns": | |
335 | raise Exception("unknown authorization type: %s" % (auth["identifier"]["type"],)) | |
336 | dn = auth["identifier"]["value"] | |
337 | if dn not in htconf.roots: | |
338 | raise Exception("no configured ht-root for domain name %s" % (dn,)) | |
339 | for ch in auth["challenges"]: | |
340 | if ch["type"] == "http-01": | |
341 | break | |
342 | else: | |
343 | raise Exception("no http-01 challenge for %s" % (dn,)) | |
344 | root = htconf.roots[dn] | |
345 | tokid, tokval = httptoken(acct, ch) | |
346 | tokpath = os.path.join(root, tokid); | |
347 | fp = open(tokpath, "w") | |
348 | try: | |
349 | with fp: | |
350 | fp.write(tokval) | |
351 | with req("http://%s/.well-known/acme-challenge/%s" % (dn, tokid)) as resp: | |
352 | if resp.read().decode("utf-8") != tokval: | |
353 | raise Exception("challenge from %s does not match written value" % (dn,)) | |
354 | for n in range(30): | |
355 | resp, headers = jreq(ch["url"], {}, acct) | |
356 | if resp["status"] == "processing": | |
357 | time.sleep(2) | |
db705a3b FT |
358 | elif resp["status"] == "pending": |
359 | # I don't think this should happen, but it | |
360 | # does. LE bug? Anyway, just retry. | |
62b251ca FT |
361 | if n < 5: |
362 | time.sleep(2) | |
363 | else: | |
364 | break | |
61d08fc2 FT |
365 | elif resp["status"] == "valid": |
366 | break | |
367 | else: | |
368 | raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"])) | |
369 | else: | |
370 | raise Exception("challenge processing timed out for %s" % (dn,)) | |
371 | finally: | |
372 | os.unlink(tokpath) | |
373 | ||
f1b49ff6 | 374 | ### Invocation and commands |
cc8619b5 | 375 | |
28d5a321 | 376 | invdata = threading.local() |
cc8619b5 FT |
377 | commands = {} |
378 | ||
28d5a321 FT |
379 | class usageerr(msgerror): |
380 | def __init__(self): | |
381 | self.cmd = invdata.cmd | |
382 | ||
383 | def report(self, out): | |
384 | out.write("%s\n" % (self.cmd.__doc__,)) | |
385 | ||
f1b49ff6 FT |
386 | ## User commands |
387 | ||
cc8619b5 FT |
388 | def cmd_reg(args): |
389 | "usage: acmecert reg [OUTPUT-FILE]" | |
390 | acct = register() | |
bfe6116d | 391 | os.umask(0o077) |
cc8619b5 FT |
392 | with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp: |
393 | acct.write(fp) | |
394 | commands["reg"] = cmd_reg | |
395 | ||
396 | def cmd_validate_acct(args): | |
397 | "usage: acmecert validate-acct ACCOUNT-FILE" | |
398 | if len(args) < 2: raise usageerr() | |
399 | with maybeopen(args[1], "r") as fp: | |
40a14578 | 400 | account.read(fp).validate() |
cc8619b5 FT |
401 | commands["validate-acct"] = cmd_validate_acct |
402 | ||
403 | def cmd_acct_info(args): | |
404 | "usage: acmecert acct-info ACCOUNT-FILE" | |
405 | if len(args) < 2: raise usageerr() | |
406 | with maybeopen(args[1], "r") as fp: | |
407 | pprint.pprint(account.read(fp).getinfo()) | |
9cef04aa | 408 | commands["acct-info"] = cmd_acct_info |
cc8619b5 FT |
409 | |
410 | def cmd_order(args): | |
411 | "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]" | |
8cea2234 | 412 | if len(args) < 3: raise usageerr() |
cc8619b5 FT |
413 | with maybeopen(args[1], "r") as fp: |
414 | acct = account.read(fp) | |
415 | with maybeopen(args[2], "r") as fp: | |
416 | csr = signreq.read(fp) | |
417 | order = mkorder(acct, csr) | |
418 | with maybeopen(args[3] if len(args) > 3 else "-", "w") as fp: | |
419 | fp.write("%s\n" % (order["acmecert.location"])) | |
420 | commands["order"] = cmd_order | |
421 | ||
422 | def cmd_http_auth(args): | |
423 | "usage: acmecert http-auth ACCOUNT-FILE HTTP-CONFIG {ORDER-ID|ORDER-FILE}" | |
424 | if len(args) < 4: raise usageerr() | |
425 | with maybeopen(args[1], "r") as fp: | |
426 | acct = account.read(fp) | |
427 | with maybeopen(args[2], "r") as fp: | |
428 | htconf = htconfig.read(fp) | |
429 | if "://" in args[3]: | |
430 | orderid = args[3] | |
431 | else: | |
432 | with maybeopen(args[3], "r") as fp: | |
433 | orderid = fp.readline().strip() | |
434 | authorder(acct, htconf, orderid) | |
435 | commands["http-auth"] = cmd_http_auth | |
436 | ||
437 | def cmd_get(args): | |
438 | "usage: acmecert get ACCOUNT-FILE CSR {ORDER-ID|ORDER-FILE}" | |
439 | if len(args) < 4: raise usageerr() | |
440 | with maybeopen(args[1], "r") as fp: | |
441 | acct = account.read(fp) | |
442 | with maybeopen(args[2], "r") as fp: | |
443 | csr = signreq.read(fp) | |
444 | if "://" in args[3]: | |
445 | orderid = args[3] | |
446 | else: | |
447 | with maybeopen(args[3], "r") as fp: | |
448 | orderid = fp.readline().strip() | |
449 | sys.stdout.write(finalize(acct, csr, orderid)) | |
450 | commands["get"] = cmd_get | |
451 | ||
452 | def cmd_http_order(args): | |
453 | "usage: acmecert http-order ACCOUNT-FILE CSR HTTP-CONFIG [OUTPUT-FILE]" | |
454 | if len(args) < 4: raise usageerr() | |
455 | with maybeopen(args[1], "r") as fp: | |
456 | acct = account.read(fp) | |
457 | with maybeopen(args[2], "r") as fp: | |
458 | csr = signreq.read(fp) | |
459 | with maybeopen(args[3], "r") as fp: | |
460 | htconf = htconfig.read(fp) | |
461 | orderid = mkorder(acct, csr)["acmecert.location"] | |
462 | authorder(acct, htconf, orderid) | |
463 | with maybeopen(args[4] if len(args) > 4 else "-", "w") as fp: | |
464 | fp.write(finalize(acct, csr, orderid)) | |
465 | commands["http-order"] = cmd_http_order | |
466 | ||
467 | def cmd_check_cert(args): | |
468 | "usage: acmecert check-cert CERT-FILE TIME-SPEC" | |
469 | if len(args) < 3: raise usageerr() | |
470 | with maybeopen(args[1], "r") as fp: | |
471 | crt = certificate.read(fp) | |
472 | sys.exit(1 if crt.expiring(args[2]) else 0) | |
473 | commands["check-cert"] = cmd_check_cert | |
474 | ||
475 | def cmd_directory(args): | |
476 | "usage: acmecert directory" | |
477 | pprint.pprint(directory()) | |
478 | commands["directory"] = cmd_directory | |
479 | ||
f1b49ff6 FT |
480 | ## Main invocation |
481 | ||
61d08fc2 | 482 | def usage(out): |
cc8619b5 FT |
483 | out.write("usage: acmecert [-D SERVICE] COMMAND [ARGS...]\n") |
484 | out.write(" acmecert -h [COMMAND]\n") | |
485 | buf = " COMMAND is any of: " | |
486 | f = True | |
487 | for cmd in commands: | |
488 | if len(buf) + len(cmd) > 70: | |
489 | out.write("%s\n" % (buf,)) | |
490 | buf = " " | |
491 | f = True | |
492 | if not f: | |
493 | buf += ", " | |
494 | buf += cmd | |
495 | f = False | |
496 | if not f: | |
497 | out.write("%s\n" % (buf,)) | |
61d08fc2 FT |
498 | |
499 | def main(argv): | |
500 | global service | |
501 | opts, args = getopt.getopt(argv[1:], "hD:") | |
502 | for o, a in opts: | |
503 | if o == "-h": | |
cc8619b5 FT |
504 | if len(args) > 0: |
505 | cmd = commands.get(args[0]) | |
506 | if cmd is None: | |
507 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) | |
508 | sys.exit(1) | |
509 | sys.stdout.write("%s\n" % (cmd.__doc__,)) | |
510 | else: | |
511 | usage(sys.stdout) | |
61d08fc2 FT |
512 | sys.exit(0) |
513 | elif o == "-D": | |
514 | service = a | |
515 | if len(args) < 1: | |
516 | usage(sys.stderr) | |
517 | sys.exit(1) | |
cc8619b5 FT |
518 | cmd = commands.get(args[0]) |
519 | if cmd is None: | |
61d08fc2 FT |
520 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) |
521 | usage(sys.stderr) | |
522 | sys.exit(1) | |
cc8619b5 | 523 | try: |
28d5a321 FT |
524 | try: |
525 | invdata.cmd = cmd | |
526 | cmd(args) | |
527 | finally: | |
528 | invdata.cmd = None | |
529 | except msgerror as exc: | |
530 | exc.report(sys.stderr) | |
cc8619b5 | 531 | sys.exit(1) |
61d08fc2 FT |
532 | |
533 | if __name__ == "__main__": | |
534 | try: | |
535 | main(sys.argv) | |
536 | except KeyboardInterrupt: | |
537 | signal.signal(signal.SIGINT, signal.SIG_DFL) | |
538 | os.kill(os.getpid(), signal.SIGINT) |