acmecert: Reorganized a bit.
[utils.git] / acmecert
1 #!/usr/bin/python3
2
3 #### ACME client (only http-01 challenges supported thus far)
4
5 import sys, os, getopt, binascii, json, pprint, signal, time, threading
6 import urllib.request
7 import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
8
9 ### General utilities
10
11 class msgerror(Exception):
12     def report(self, out):
13         out.write("acmecert: undefined error\n")
14
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
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)
33         else:
34             self.opened = True
35             self.fp = open(name, mode)
36
37     def __enter__(self):
38         return self.fp
39
40     def __exit__(self, *excinfo):
41         if self.opened:
42             self.fp.close()
43         return False
44
45 ### Crypto utilities
46
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
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()
90             resp = openssl.stdout.read().decode("utf-8")
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
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
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
247 ### ACME protocol
248
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
257
258 def register(keysize=4096):
259     key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read)
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
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
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)
358                     elif resp["status"] == "pending":
359                         # I don't think this should happen, but it
360                         # does. LE bug? Anyway, just retry.
361                         if n < 5:
362                             time.sleep(2)
363                         else:
364                             break
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
374 ### Invocation and commands
375
376 invdata = threading.local()
377 commands = {}
378
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
386 ## User commands
387
388 def cmd_reg(args):
389     "usage: acmecert reg [OUTPUT-FILE]"
390     acct = register()
391     os.umask(0o077)
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:
400         account.read(fp).validate()
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())
408 commands["acct-info"] = cmd_acct_info
409
410 def cmd_order(args):
411     "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
412     if len(args) < 3: raise usageerr()
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
480 ## Main invocation
481
482 def usage(out):
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,))
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":
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)
512             sys.exit(0)
513         elif o == "-D":
514             service = a
515     if len(args) < 1:
516         usage(sys.stderr)
517         sys.exit(1)
518     cmd = commands.get(args[0])
519     if cmd is None:
520         sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
521         usage(sys.stderr)
522         sys.exit(1)
523     try:
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)
531         sys.exit(1)
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)