acmecert: Reorganized a bit.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
f1b49ff6
FT
3#### ACME client (only http-01 challenges supported thus far)
4
28d5a321 5import sys, os, getopt, binascii, json, pprint, signal, time, threading
61d08fc2
FT
6import urllib.request
7import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
8
f1b49ff6
FT
9### General utilities
10
28d5a321
FT
11class msgerror(Exception):
12 def report(self, out):
13 out.write("acmecert: undefined error\n")
14
61d08fc2
FT
15def base64url(dat):
16 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
17
18def ebignum(num):
19 h = "%x" % num
20 if len(h) % 2 == 1: h = "0" + h
21 return base64url(binascii.a2b_hex(h))
22
f1b49ff6
FT
23class 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)
0305dfdc 33 else:
f1b49ff6
FT
34 self.opened = True
35 self.fp = open(name, mode)
0305dfdc 36
f1b49ff6
FT
37 def __enter__(self):
38 return self.fp
0305dfdc 39
f1b49ff6
FT
40 def __exit__(self, *excinfo):
41 if self.opened:
42 self.fp.close()
43 return False
44
45### Crypto utilities
61d08fc2 46
14a46eff
FT
47class 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
61d08fc2
FT
83class 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()
14a46eff 90 resp = openssl.stdout.read().decode("utf-8")
61d08fc2
FT
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
f1b49ff6
FT
120### Somewhat general request utilities
121
122def getnonce():
123 with urllib.request.urlopen(directory()["newNonce"]) as resp:
124 resp.read()
125 return resp.headers["Replay-Nonce"]
126
127def 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
138class 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
180def 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
61d08fc2
FT
200class 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
212class 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
f1b49ff6 247### ACME protocol
61d08fc2 248
f1b49ff6
FT
249service = "https://acme-v02.api.letsencrypt.org/directory"
250_directory = None
251def directory():
252 global _directory
253 if _directory is None:
254 with req(service) as resp:
255 _directory = json.load(resp)
256 return _directory
61d08fc2
FT
257
258def register(keysize=4096):
259 key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read)
61d08fc2
FT
260 data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key))
261 return account(headers["Location"], key)
262
263def 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
268def 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
f1b49ff6
FT
275def 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
299class 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
61d08fc2
FT
316def 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)
db705a3b
FT
358 elif resp["status"] == "pending":
359 # I don't think this should happen, but it
360 # does. LE bug? Anyway, just retry.
62b251ca
FT
361 if n < 5:
362 time.sleep(2)
363 else:
364 break
61d08fc2
FT
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
f1b49ff6 374### Invocation and commands
cc8619b5 375
28d5a321 376invdata = threading.local()
cc8619b5
FT
377commands = {}
378
28d5a321
FT
379class 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
f1b49ff6
FT
386## User commands
387
cc8619b5
FT
388def cmd_reg(args):
389 "usage: acmecert reg [OUTPUT-FILE]"
390 acct = register()
bfe6116d 391 os.umask(0o077)
cc8619b5
FT
392 with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp:
393 acct.write(fp)
394commands["reg"] = cmd_reg
395
396def 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:
40a14578 400 account.read(fp).validate()
cc8619b5
FT
401commands["validate-acct"] = cmd_validate_acct
402
403def 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())
9cef04aa 408commands["acct-info"] = cmd_acct_info
cc8619b5
FT
409
410def cmd_order(args):
411 "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
8cea2234 412 if len(args) < 3: raise usageerr()
cc8619b5
FT
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"]))
420commands["order"] = cmd_order
421
422def 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)
435commands["http-auth"] = cmd_http_auth
436
437def 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))
450commands["get"] = cmd_get
451
452def 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))
465commands["http-order"] = cmd_http_order
466
467def 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)
473commands["check-cert"] = cmd_check_cert
474
475def cmd_directory(args):
476 "usage: acmecert directory"
477 pprint.pprint(directory())
478commands["directory"] = cmd_directory
479
f1b49ff6
FT
480## Main invocation
481
61d08fc2 482def usage(out):
cc8619b5
FT
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,))
61d08fc2
FT
498
499def main(argv):
500 global service
501 opts, args = getopt.getopt(argv[1:], "hD:")
502 for o, a in opts:
503 if o == "-h":
cc8619b5
FT
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)
61d08fc2
FT
512 sys.exit(0)
513 elif o == "-D":
514 service = a
515 if len(args) < 1:
516 usage(sys.stderr)
517 sys.exit(1)
cc8619b5
FT
518 cmd = commands.get(args[0])
519 if cmd is None:
61d08fc2
FT
520 sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
521 usage(sys.stderr)
522 sys.exit(1)
cc8619b5 523 try:
28d5a321
FT
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)
cc8619b5 531 sys.exit(1)
61d08fc2
FT
532
533if __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)