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