acmecer: Accept "pending" challenge status after submission.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
3import sys, os, getopt, binascii, json, pprint, signal, time
4import urllib.request
5import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
6
7service = "https://acme-v02.api.letsencrypt.org/directory"
8_directory = None
9def 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
16def base64url(dat):
17 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
18
19def ebignum(num):
20 h = "%x" % num
21 if len(h) % 2 == 1: h = "0" + h
22 return base64url(binascii.a2b_hex(h))
23
24def getnonce():
25 with urllib.request.urlopen(directory()["newNonce"]) as resp:
26 resp.read()
27 return resp.headers["Replay-Nonce"]
28
29def 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
40def 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
14a46eff
FT
53class 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
61d08fc2
FT
89class 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()
14a46eff 96 resp = openssl.stdout.read().decode("utf-8")
61d08fc2
FT
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
126class 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
138class 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
173class 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
190def 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
197def 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
202def 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
209def 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)
db705a3b
FT
251 elif resp["status"] == "pending":
252 # I don't think this should happen, but it
253 # does. LE bug? Anyway, just retry.
254 break
61d08fc2
FT
255 elif resp["status"] == "valid":
256 break
257 else:
258 raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"]))
259 else:
260 raise Exception("challenge processing timed out for %s" % (dn,))
261 finally:
262 os.unlink(tokpath)
263
264def finalize(acct, csr, orderid):
265 order, headers = jreq(orderid, None, acct)
266 if order["status"] == "valid":
267 pass
268 elif order["status"] == "ready":
269 jreq(order["finalize"], {"csr": base64url(csr.der())}, acct)
270 for n in range(30):
271 resp, headers = jreq(orderid, None, acct)
272 if resp["status"] == "processing":
273 time.sleep(2)
274 elif resp["status"] == "valid":
275 order = resp
276 break
277 else:
278 raise Exception("unexpected order status when finalizing: %s" % resp["status"])
279 else:
280 raise Exception("order finalization timed out")
281 else:
282 raise Exception("unexpected order state when finalizing: %s" % (order["status"],))
283 with req(order["certificate"]) as resp:
284 return resp.read().decode("us-ascii")
285
cc8619b5
FT
286class maybeopen(object):
287 def __init__(self, name, mode):
288 if name == "-":
289 self.opened = False
290 if mode == "r":
291 self.fp = sys.stdin
292 elif mode == "w":
293 self.fp = sys.stdout
294 else:
295 raise ValueError(mode)
296 else:
297 self.opened = True
298 self.fp = open(name, mode)
299
300 def __enter__(self):
301 return self.fp
302
303 def __exit__(self, *excinfo):
304 if self.opened:
305 self.fp.close()
306 return False
307
308class usageerr(Exception):
309 pass
310
311commands = {}
312
313def cmd_reg(args):
314 "usage: acmecert reg [OUTPUT-FILE]"
315 acct = register()
bfe6116d 316 os.umask(0o077)
cc8619b5
FT
317 with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp:
318 acct.write(fp)
319commands["reg"] = cmd_reg
320
321def cmd_validate_acct(args):
322 "usage: acmecert validate-acct ACCOUNT-FILE"
323 if len(args) < 2: raise usageerr()
324 with maybeopen(args[1], "r") as fp:
40a14578 325 account.read(fp).validate()
cc8619b5
FT
326commands["validate-acct"] = cmd_validate_acct
327
328def cmd_acct_info(args):
329 "usage: acmecert acct-info ACCOUNT-FILE"
330 if len(args) < 2: raise usageerr()
331 with maybeopen(args[1], "r") as fp:
332 pprint.pprint(account.read(fp).getinfo())
9cef04aa 333commands["acct-info"] = cmd_acct_info
cc8619b5
FT
334
335def cmd_order(args):
336 "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
8cea2234 337 if len(args) < 3: raise usageerr()
cc8619b5
FT
338 with maybeopen(args[1], "r") as fp:
339 acct = account.read(fp)
340 with maybeopen(args[2], "r") as fp:
341 csr = signreq.read(fp)
342 order = mkorder(acct, csr)
343 with maybeopen(args[3] if len(args) > 3 else "-", "w") as fp:
344 fp.write("%s\n" % (order["acmecert.location"]))
345commands["order"] = cmd_order
346
347def cmd_http_auth(args):
348 "usage: acmecert http-auth ACCOUNT-FILE HTTP-CONFIG {ORDER-ID|ORDER-FILE}"
349 if len(args) < 4: raise usageerr()
350 with maybeopen(args[1], "r") as fp:
351 acct = account.read(fp)
352 with maybeopen(args[2], "r") as fp:
353 htconf = htconfig.read(fp)
354 if "://" in args[3]:
355 orderid = args[3]
356 else:
357 with maybeopen(args[3], "r") as fp:
358 orderid = fp.readline().strip()
359 authorder(acct, htconf, orderid)
360commands["http-auth"] = cmd_http_auth
361
362def cmd_get(args):
363 "usage: acmecert get ACCOUNT-FILE CSR {ORDER-ID|ORDER-FILE}"
364 if len(args) < 4: raise usageerr()
365 with maybeopen(args[1], "r") as fp:
366 acct = account.read(fp)
367 with maybeopen(args[2], "r") as fp:
368 csr = signreq.read(fp)
369 if "://" in args[3]:
370 orderid = args[3]
371 else:
372 with maybeopen(args[3], "r") as fp:
373 orderid = fp.readline().strip()
374 sys.stdout.write(finalize(acct, csr, orderid))
375commands["get"] = cmd_get
376
377def cmd_http_order(args):
378 "usage: acmecert http-order ACCOUNT-FILE CSR HTTP-CONFIG [OUTPUT-FILE]"
379 if len(args) < 4: raise usageerr()
380 with maybeopen(args[1], "r") as fp:
381 acct = account.read(fp)
382 with maybeopen(args[2], "r") as fp:
383 csr = signreq.read(fp)
384 with maybeopen(args[3], "r") as fp:
385 htconf = htconfig.read(fp)
386 orderid = mkorder(acct, csr)["acmecert.location"]
387 authorder(acct, htconf, orderid)
388 with maybeopen(args[4] if len(args) > 4 else "-", "w") as fp:
389 fp.write(finalize(acct, csr, orderid))
390commands["http-order"] = cmd_http_order
391
392def cmd_check_cert(args):
393 "usage: acmecert check-cert CERT-FILE TIME-SPEC"
394 if len(args) < 3: raise usageerr()
395 with maybeopen(args[1], "r") as fp:
396 crt = certificate.read(fp)
397 sys.exit(1 if crt.expiring(args[2]) else 0)
398commands["check-cert"] = cmd_check_cert
399
400def cmd_directory(args):
401 "usage: acmecert directory"
402 pprint.pprint(directory())
403commands["directory"] = cmd_directory
404
61d08fc2 405def usage(out):
cc8619b5
FT
406 out.write("usage: acmecert [-D SERVICE] COMMAND [ARGS...]\n")
407 out.write(" acmecert -h [COMMAND]\n")
408 buf = " COMMAND is any of: "
409 f = True
410 for cmd in commands:
411 if len(buf) + len(cmd) > 70:
412 out.write("%s\n" % (buf,))
413 buf = " "
414 f = True
415 if not f:
416 buf += ", "
417 buf += cmd
418 f = False
419 if not f:
420 out.write("%s\n" % (buf,))
61d08fc2
FT
421
422def main(argv):
423 global service
424 opts, args = getopt.getopt(argv[1:], "hD:")
425 for o, a in opts:
426 if o == "-h":
cc8619b5
FT
427 if len(args) > 0:
428 cmd = commands.get(args[0])
429 if cmd is None:
430 sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
431 sys.exit(1)
432 sys.stdout.write("%s\n" % (cmd.__doc__,))
433 else:
434 usage(sys.stdout)
61d08fc2
FT
435 sys.exit(0)
436 elif o == "-D":
437 service = a
438 if len(args) < 1:
439 usage(sys.stderr)
440 sys.exit(1)
cc8619b5
FT
441 cmd = commands.get(args[0])
442 if cmd is None:
61d08fc2
FT
443 sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
444 usage(sys.stderr)
445 sys.exit(1)
cc8619b5
FT
446 try:
447 cmd(args)
448 except usageerr:
449 sys.stderr.write("%s\n" % (cmd.__doc__,))
450 sys.exit(1)
61d08fc2
FT
451
452if __name__ == "__main__":
453 try:
454 main(sys.argv)
455 except KeyboardInterrupt:
456 signal.signal(signal.SIGINT, signal.SIG_DFL)
457 os.kill(os.getpid(), signal.SIGINT)