acmecert: Handle application/problem responses better.
[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:
0305dfdc 17 _directory = json.load(resp)
61d08fc2
FT
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
0305dfdc
FT
44class 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
61d08fc2
FT
86def 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}
0305dfdc
FT
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
61d08fc2 103
14a46eff
FT
104class 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
61d08fc2
FT
140class 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()
14a46eff 147 resp = openssl.stdout.read().decode("utf-8")
61d08fc2
FT
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
177class 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
189class 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
224class 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
241def register(keysize=4096):
242 key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read)
61d08fc2
FT
243 data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key))
244 return account(headers["Location"], key)
245
246def 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
251def 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
258def 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)
db705a3b
FT
300 elif resp["status"] == "pending":
301 # I don't think this should happen, but it
302 # does. LE bug? Anyway, just retry.
62b251ca
FT
303 if n < 5:
304 time.sleep(2)
305 else:
306 break
61d08fc2
FT
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
316def 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
cc8619b5
FT
338class 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
28d5a321 360invdata = threading.local()
cc8619b5
FT
361commands = {}
362
28d5a321
FT
363class 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
cc8619b5
FT
370def cmd_reg(args):
371 "usage: acmecert reg [OUTPUT-FILE]"
372 acct = register()
bfe6116d 373 os.umask(0o077)
cc8619b5
FT
374 with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp:
375 acct.write(fp)
376commands["reg"] = cmd_reg
377
378def 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:
40a14578 382 account.read(fp).validate()
cc8619b5
FT
383commands["validate-acct"] = cmd_validate_acct
384
385def 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())
9cef04aa 390commands["acct-info"] = cmd_acct_info
cc8619b5
FT
391
392def cmd_order(args):
393 "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
8cea2234 394 if len(args) < 3: raise usageerr()
cc8619b5
FT
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"]))
402commands["order"] = cmd_order
403
404def 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)
417commands["http-auth"] = cmd_http_auth
418
419def 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))
432commands["get"] = cmd_get
433
434def 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))
447commands["http-order"] = cmd_http_order
448
449def 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)
455commands["check-cert"] = cmd_check_cert
456
457def cmd_directory(args):
458 "usage: acmecert directory"
459 pprint.pprint(directory())
460commands["directory"] = cmd_directory
461
61d08fc2 462def usage(out):
cc8619b5
FT
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,))
61d08fc2
FT
478
479def main(argv):
480 global service
481 opts, args = getopt.getopt(argv[1:], "hD:")
482 for o, a in opts:
483 if o == "-h":
cc8619b5
FT
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)
61d08fc2
FT
492 sys.exit(0)
493 elif o == "-D":
494 service = a
495 if len(args) < 1:
496 usage(sys.stderr)
497 sys.exit(1)
cc8619b5
FT
498 cmd = commands.get(args[0])
499 if cmd is None:
61d08fc2
FT
500 sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
501 usage(sys.stderr)
502 sys.exit(1)
cc8619b5 503 try:
28d5a321
FT
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)
cc8619b5 511 sys.exit(1)
61d08fc2
FT
512
513if __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)