acmecert: Make error reporting more flexible.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
28d5a321 3import sys, os, getopt, binascii, json, pprint, signal, time, threading
61d08fc2
FT
4import urllib.request
5import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
6
28d5a321
FT
7class msgerror(Exception):
8 def report(self, out):
9 out.write("acmecert: undefined error\n")
10
61d08fc2
FT
11service = "https://acme-v02.api.letsencrypt.org/directory"
12_directory = None
13def 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
20def base64url(dat):
21 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
22
23def ebignum(num):
24 h = "%x" % num
25 if len(h) % 2 == 1: h = "0" + h
26 return base64url(binascii.a2b_hex(h))
27
28def getnonce():
29 with urllib.request.urlopen(directory()["newNonce"]) as resp:
30 resp.read()
31 return resp.headers["Replay-Nonce"]
32
33def 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
44def 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
14a46eff
FT
57class 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
61d08fc2
FT
93class 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()
14a46eff 100 resp = openssl.stdout.read().decode("utf-8")
61d08fc2
FT
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
130class 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
142class 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
177class 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
194def 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
201def 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
206def 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
213def 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)
db705a3b
FT
255 elif resp["status"] == "pending":
256 # I don't think this should happen, but it
257 # does. LE bug? Anyway, just retry.
62b251ca
FT
258 if n < 5:
259 time.sleep(2)
260 else:
261 break
61d08fc2
FT
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
271def 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
cc8619b5
FT
293class 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
28d5a321 315invdata = threading.local()
cc8619b5
FT
316commands = {}
317
28d5a321
FT
318class 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
cc8619b5
FT
325def cmd_reg(args):
326 "usage: acmecert reg [OUTPUT-FILE]"
327 acct = register()
bfe6116d 328 os.umask(0o077)
cc8619b5
FT
329 with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp:
330 acct.write(fp)
331commands["reg"] = cmd_reg
332
333def 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:
40a14578 337 account.read(fp).validate()
cc8619b5
FT
338commands["validate-acct"] = cmd_validate_acct
339
340def 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())
9cef04aa 345commands["acct-info"] = cmd_acct_info
cc8619b5
FT
346
347def cmd_order(args):
348 "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
8cea2234 349 if len(args) < 3: raise usageerr()
cc8619b5
FT
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"]))
357commands["order"] = cmd_order
358
359def 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)
372commands["http-auth"] = cmd_http_auth
373
374def 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))
387commands["get"] = cmd_get
388
389def 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))
402commands["http-order"] = cmd_http_order
403
404def 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)
410commands["check-cert"] = cmd_check_cert
411
412def cmd_directory(args):
413 "usage: acmecert directory"
414 pprint.pprint(directory())
415commands["directory"] = cmd_directory
416
61d08fc2 417def usage(out):
cc8619b5
FT
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,))
61d08fc2
FT
433
434def main(argv):
435 global service
436 opts, args = getopt.getopt(argv[1:], "hD:")
437 for o, a in opts:
438 if o == "-h":
cc8619b5
FT
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)
61d08fc2
FT
447 sys.exit(0)
448 elif o == "-D":
449 service = a
450 if len(args) < 1:
451 usage(sys.stderr)
452 sys.exit(1)
cc8619b5
FT
453 cmd = commands.get(args[0])
454 if cmd is None:
61d08fc2
FT
455 sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
456 usage(sys.stderr)
457 sys.exit(1)
cc8619b5 458 try:
28d5a321
FT
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)
cc8619b5 466 sys.exit(1)
61d08fc2
FT
467
468if __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)