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