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