Commit | Line | Data |
---|---|---|
61d08fc2 FT |
1 | #!/usr/bin/python3 |
2 | ||
f1b49ff6 FT |
3 | #### ACME client (only http-01 challenges supported thus far) |
4 | ||
d2252d10 | 5 | import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading |
61d08fc2 | 6 | import urllib.request |
61d08fc2 | 7 | |
f1b49ff6 FT |
8 | ### General utilities |
9 | ||
28d5a321 FT |
10 | class msgerror(Exception): |
11 | def report(self, out): | |
12 | out.write("acmecert: undefined error\n") | |
13 | ||
61d08fc2 FT |
14 | def base64url(dat): |
15 | return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip() | |
16 | ||
17 | def ebignum(num): | |
18 | h = "%x" % num | |
19 | if len(h) % 2 == 1: h = "0" + h | |
20 | return base64url(binascii.a2b_hex(h)) | |
21 | ||
f1b49ff6 FT |
22 | class maybeopen(object): |
23 | def __init__(self, name, mode): | |
24 | if name == "-": | |
25 | self.opened = False | |
26 | if mode == "r": | |
27 | self.fp = sys.stdin | |
28 | elif mode == "w": | |
29 | self.fp = sys.stdout | |
30 | else: | |
31 | raise ValueError(mode) | |
0305dfdc | 32 | else: |
f1b49ff6 FT |
33 | self.opened = True |
34 | self.fp = open(name, mode) | |
0305dfdc | 35 | |
f1b49ff6 FT |
36 | def __enter__(self): |
37 | return self.fp | |
0305dfdc | 38 | |
f1b49ff6 FT |
39 | def __exit__(self, *excinfo): |
40 | if self.opened: | |
41 | self.fp.close() | |
42 | return False | |
43 | ||
44 | ### Crypto utilities | |
61d08fc2 | 45 | |
0756cac4 FT |
46 | _cryptobke = None |
47 | def cryptobke(): | |
48 | global _cryptobke | |
49 | if _cryptobke is None: | |
50 | from cryptography.hazmat import backends | |
51 | _cryptobke = backends.default_backend() | |
52 | return _cryptobke | |
53 | ||
d2252d10 FT |
54 | class dererror(Exception): |
55 | pass | |
56 | ||
57 | class pemerror(Exception): | |
58 | pass | |
59 | ||
60 | def pemdec(pem, ptypes): | |
61 | if isinstance(ptypes, str): | |
62 | ptypes = [ptypes] | |
63 | p = 0 | |
64 | while True: | |
65 | p = pem.find("-----BEGIN ", p) | |
66 | if p < 0: | |
67 | raise pemerror("could not find any %s in PEM-encoded data" % (ptypes,)) | |
68 | p2 = pem.find("-----", p + 11) | |
69 | if p2 < 0: | |
70 | raise pemerror("incomplete PEM header") | |
71 | ptype = pem[p + 11 : p2] | |
72 | if ptype not in ptypes: | |
73 | p = p2 + 5 | |
74 | continue | |
75 | p3 = pem.find("-----END " + ptype + "-----", p2 + 5) | |
76 | if p3 < 0: | |
77 | raise pemerror("incomplete PEM data") | |
78 | pem = pem[p2 + 5 : p3] | |
79 | return binascii.a2b_base64(pem) | |
80 | ||
81 | class derdecoder(object): | |
82 | def __init__(self, data, offset=0, size=None): | |
83 | self.data = data | |
84 | self.offset = offset | |
85 | self.size = len(data) if size is None else size | |
86 | ||
87 | def end(self): | |
88 | return self.offset >= self.size | |
89 | ||
90 | def byte(self): | |
91 | if self.offset >= self.size: | |
92 | raise dererror("unexpected end-of-data") | |
93 | ret = self.data[self.offset] | |
94 | self.offset += 1 | |
95 | return ret | |
96 | ||
97 | def splice(self, ln): | |
98 | if self.offset + ln > self.size: | |
99 | raise dererror("unexpected end-of-data") | |
100 | ret = self.data[self.offset : self.offset + ln] | |
101 | self.offset += ln | |
102 | return ret | |
103 | ||
104 | def dectag(self): | |
105 | h = self.byte() | |
106 | cl = (h & 0xc0) >> 6 | |
107 | cons = (h & 0x20) != 0 | |
108 | tag = h & 0x1f | |
109 | if tag == 0x1f: | |
110 | raise dererror("extended type tags not supported") | |
111 | return cl, cons, tag | |
112 | ||
113 | def declen(self): | |
114 | h = self.byte() | |
115 | if (h & 0x80) == 0: | |
116 | return h | |
117 | if h == 0x80: | |
118 | raise dererror("indefinite lengths not supported in DER") | |
119 | if h == 0xff: | |
120 | raise dererror("invalid length byte") | |
121 | n = h & 0x7f | |
122 | ret = 0 | |
123 | for i in range(n): | |
124 | ret = (ret << 8) + self.byte() | |
125 | return ret | |
126 | ||
127 | def get(self): | |
128 | cl, cons, tag = self.dectag() | |
129 | ln = self.declen() | |
130 | return cons, cl, tag, self.splice(ln) | |
131 | ||
132 | def getcons(self, ckcl, cktag): | |
133 | cons, cl, tag, data = self.get() | |
134 | if not cons: | |
135 | raise dererror("expected constructed value") | |
136 | if (ckcl != None and ckcl != cl) or (cktag != None and cktag != tag): | |
137 | raise dererror("unexpected value tag: got (%d, %d), expected (%d, %d)" % (cl, tag, ckcl, cktag)) | |
138 | return derdecoder(data) | |
139 | ||
140 | def getint(self): | |
141 | cons, cl, tag, data = self.get() | |
142 | if (cons, cl, tag) == (False, 0, 2): | |
143 | ret = 0 | |
144 | for b in data: | |
145 | ret = (ret << 8) + b | |
146 | return ret | |
147 | raise dererror("unexpected integer type: (%s, %d, %d)" % (cons, cl, tag)) | |
148 | ||
149 | def getstr(self): | |
150 | cons, cl, tag, data = self.get() | |
151 | if (cons, cl, tag) == (False, 0, 12): | |
152 | return data.decode("utf-8") | |
153 | if (cons, cl, tag) == (False, 0, 13): | |
154 | return data.decode("us-ascii") | |
155 | if (cons, cl, tag) == (False, 0, 22): | |
156 | return data.decode("us-ascii") | |
157 | if (cons, cl, tag) == (False, 0, 30): | |
158 | return data.decode("utf-16-be") | |
159 | raise dererror("unexpected string type: (%s, %d, %d)" % (cons, cl, tag)) | |
160 | ||
161 | def getbytes(self): | |
162 | cons, cl, tag, data = self.get() | |
163 | if (cons, cl, tag) == (False, 0, 4): | |
164 | return data | |
165 | raise dererror("unexpected byte-string type: (%s, %d, %d)" % (cons, cl, tag)) | |
166 | ||
167 | def getoid(self): | |
168 | cons, cl, tag, data = self.get() | |
169 | if (cons, cl, tag) == (False, 0, 6): | |
170 | ret = [] | |
171 | ret.append(data[0] // 40) | |
172 | ret.append(data[0] % 40) | |
173 | p = 1 | |
174 | while p < len(data): | |
175 | n = 0 | |
176 | v = data[p] | |
177 | p += 1 | |
178 | while v & 0x80: | |
179 | n = (n + (v & 0x7f)) * 128 | |
180 | v = data[p] | |
181 | p += 1 | |
182 | n += v | |
183 | ret.append(n) | |
184 | return tuple(ret) | |
185 | raise dererror("unexpected object-id type: (%s, %d, %d)" % (cons, cl, tag)) | |
186 | ||
187 | @staticmethod | |
188 | def parsetime(data, c): | |
189 | if c: | |
190 | y = int(data[0:4]) | |
191 | data = data[4:] | |
192 | else: | |
193 | y = int(data[0:2]) | |
194 | y += 1900 if y > 50 else 2000 | |
195 | data = data[2:] | |
196 | m = int(data[0:2]) | |
197 | d = int(data[2:4]) | |
198 | H = int(data[4:6]) | |
199 | data = data[6:] | |
200 | if data[:1].isdigit(): | |
201 | M = int(data[0:2]) | |
202 | data = data[2:] | |
203 | else: | |
204 | M = 0 | |
205 | if data[:1].isdigit(): | |
206 | S = int(data[0:2]) | |
207 | data = data[2:] | |
208 | else: | |
209 | S = 0 | |
210 | if data[:1] == '.': | |
211 | p = 1 | |
212 | while len(data) < p and data[p].isdigit(): | |
213 | p += 1 | |
214 | S += float("0." + data[1:p]) | |
215 | data = data[p:] | |
216 | if len(data) < 1: | |
217 | raise dererror("unspecified local time not supported for decoding") | |
218 | if data[0] == 'Z': | |
219 | tz = 0 | |
220 | elif data[0] == '+': | |
221 | tz = (int(data[1:3]) * 60) + int(data[3:5]) | |
222 | elif data[0] == '-': | |
223 | tz = -((int(data[1:3]) * 60) + int(data[3:5])) | |
224 | else: | |
225 | raise dererror("cannot parse X.690 timestamp") | |
226 | return calendar.timegm((y, m, d, H, M, S)) - (tz * 60) | |
227 | ||
228 | def gettime(self): | |
229 | cons, cl, tag, data = self.get() | |
230 | if (cons, cl, tag) == (False, 0, 23): | |
231 | return self.parsetime(data.decode("us-ascii"), False) | |
232 | if (cons, cl, tag) == (False, 0, 24): | |
233 | return self.parsetime(data.decode("us-ascii"), True) | |
234 | raise dererror("unexpected time type: (%s, %d, %d)" % (cons, cl, tag)) | |
235 | ||
236 | @classmethod | |
237 | def frompem(cls, pem, ptypes): | |
238 | return cls(pemdec(pem, ptypes)) | |
239 | ||
14a46eff | 240 | class certificate(object): |
d2252d10 FT |
241 | def __init__(self, der): |
242 | ci = der.getcons(0, 16).getcons(0, 16) | |
243 | self.ver = ci.getcons(2, 0).getint() | |
244 | self.serial = ci.getint() | |
245 | ci.getcons(0, 16) # Signature algorithm | |
246 | ci.getcons(0, 16) # Issuer | |
247 | vl = ci.getcons(0, 16) | |
248 | self.startdate = vl.gettime() | |
249 | self.enddate = vl.gettime() | |
14a46eff FT |
250 | |
251 | def expiring(self, timespec): | |
252 | if timespec.endswith("y"): | |
253 | timespec = int(timespec[:-1]) * 365 * 86400 | |
254 | elif timespec.endswith("m"): | |
255 | timespec = int(timespec[:-1]) * 30 * 86400 | |
256 | elif timespec.endswith("w"): | |
257 | timespec = int(timespec[:-1]) * 7 * 86400 | |
258 | elif timespec.endswith("d"): | |
259 | timespec = int(timespec[:-1]) * 86400 | |
260 | elif timespec.endswith("h"): | |
261 | timespec = int(timespec[:-1]) * 3600 | |
262 | else: | |
263 | timespec = int(timespec) | |
264 | return (self.enddate - time.time()) < timespec | |
265 | ||
266 | @classmethod | |
267 | def read(cls, fp): | |
d2252d10 | 268 | return cls(derdecoder.frompem(fp.read(), {"CERTIFICATE", "X509 CERTIFICATE"})) |
14a46eff | 269 | |
61d08fc2 | 270 | class signreq(object): |
d2252d10 FT |
271 | def __init__(self, der): |
272 | self.raw = der | |
273 | req = derdecoder(der).getcons(0, 16).getcons(0, 16) | |
274 | self.ver = req.getint() | |
275 | req.getcons(0, 16) # Subject | |
276 | req.getcons(0, 16) # Public key | |
277 | self.altnames = [] | |
278 | if not req.end(): | |
279 | attrs = req.getcons(2, 0) | |
280 | while not attrs.end(): | |
281 | attr = attrs.getcons(0, 16) | |
282 | anm = attr.getoid() | |
283 | if anm == (1, 2, 840, 113549, 1, 9, 14): | |
284 | # Certificate extension request | |
285 | exts = attr.getcons(0, 17).getcons(0, 16) | |
286 | while not exts.end(): | |
287 | ext = exts.getcons(0, 16) | |
288 | extnm = ext.getoid() | |
289 | if extnm == (2, 5, 29, 17): | |
290 | # Subject alternative names | |
291 | names = derdecoder(ext.getbytes()).getcons(0, 16) | |
292 | while not names.end(): | |
293 | cons, cl, tag, data = names.get() | |
294 | if (cons, cl, tag) == (False, 2, 2): | |
295 | self.altnames.append(("DNS", data.decode("us-ascii"))) | |
296 | ||
61d08fc2 | 297 | def domains(self): |
d2252d10 | 298 | return [nm[1] for nm in self.altnames if nm[0] == "DNS"] |
61d08fc2 FT |
299 | |
300 | def der(self): | |
d2252d10 | 301 | return self.raw |
61d08fc2 FT |
302 | |
303 | @classmethod | |
304 | def read(cls, fp): | |
d2252d10 | 305 | return cls(pemdec(fp.read(), {"CERTIFICATE REQUEST"})) |
61d08fc2 | 306 | |
f1b49ff6 FT |
307 | ### Somewhat general request utilities |
308 | ||
309 | def getnonce(): | |
310 | with urllib.request.urlopen(directory()["newNonce"]) as resp: | |
311 | resp.read() | |
312 | return resp.headers["Replay-Nonce"] | |
313 | ||
314 | def req(url, data=None, ctype=None, headers={}, method=None, **kws): | |
315 | if data is not None and not isinstance(data, bytes): | |
316 | data = json.dumps(data).encode("utf-8") | |
317 | ctype = "application/jose+json" | |
318 | req = urllib.request.Request(url, data=data, method=method) | |
319 | for hnam, hval in headers.items(): | |
320 | req.add_header(hnam, hval) | |
321 | if ctype is not None: | |
322 | req.add_header("Content-Type", ctype) | |
323 | return urllib.request.urlopen(req) | |
324 | ||
325 | class problem(msgerror): | |
326 | def __init__(self, code, data, *args, url=None, **kw): | |
327 | super().__init__(*args, **kw) | |
328 | self.code = code | |
329 | self.data = data | |
330 | self.url = url | |
331 | if not isinstance(data, dict): | |
332 | raise ValueError("unexpected problem object type: %r" % (data,)) | |
333 | ||
334 | @property | |
335 | def type(self): | |
336 | return self.data.get("type", "about:blank") | |
337 | @property | |
338 | def title(self): | |
339 | return self.data.get("title") | |
340 | @property | |
341 | def detail(self): | |
342 | return self.data.get("detail") | |
343 | ||
344 | def report(self, out): | |
345 | extra = None | |
346 | if self.title is None: | |
347 | msg = self.detail | |
348 | if "\n" in msg: | |
349 | extra, msg = msg, None | |
350 | else: | |
351 | msg = self.title | |
352 | extra = self.detail | |
353 | if msg is None: | |
354 | msg = self.data.get("type") | |
355 | if msg is not None: | |
356 | out.write("acemcert: %s: %s\n" % ( | |
357 | ("remote service error" if self.url is None else self.url), | |
358 | ("unspecified error" if msg is None else msg))) | |
359 | if extra is not None: | |
360 | out.write("%s\n" % (extra,)) | |
361 | ||
362 | @classmethod | |
363 | def read(cls, err, **kw): | |
1995d63b | 364 | self = cls(err.code, json.loads(err.read().decode("utf-8")), **kw) |
f1b49ff6 FT |
365 | return self |
366 | ||
367 | def jreq(url, data, auth): | |
368 | authdata = {"alg": "RS256", "url": url, "nonce": getnonce()} | |
369 | authdata.update(auth.authdata()) | |
370 | authdata = base64url(json.dumps(authdata).encode("us-ascii")) | |
371 | if data is None: | |
372 | data = "" | |
373 | else: | |
374 | data = base64url(json.dumps(data).encode("us-ascii")) | |
375 | seal = base64url(auth.sign(("%s.%s" % (authdata, data)).encode("us-ascii"))) | |
376 | enc = {"protected": authdata, "payload": data, "signature": seal} | |
377 | try: | |
378 | with req(url, data=enc) as resp: | |
1995d63b | 379 | return json.loads(resp.read().decode("utf-8")), resp.headers |
f1b49ff6 FT |
380 | except urllib.error.HTTPError as exc: |
381 | if exc.headers["Content-Type"] == "application/problem+json": | |
382 | raise problem.read(exc, url=url) | |
383 | raise | |
384 | ||
385 | ## Authentication | |
386 | ||
61d08fc2 FT |
387 | class jwkauth(object): |
388 | def __init__(self, key): | |
389 | self.key = key | |
390 | ||
391 | def authdata(self): | |
0756cac4 FT |
392 | pub = self.key.public_key().public_numbers() |
393 | return {"jwk": {"kty": "RSA", "e": ebignum(pub.e), "n": ebignum(pub.n)}} | |
61d08fc2 FT |
394 | |
395 | def sign(self, data): | |
0756cac4 FT |
396 | from cryptography.hazmat.primitives import hashes |
397 | from cryptography.hazmat.primitives.asymmetric import padding | |
398 | return self.key.sign(data, padding.PKCS1v15(), hashes.SHA256()) | |
61d08fc2 FT |
399 | |
400 | class account(object): | |
401 | def __init__(self, uri, key): | |
402 | self.uri = uri | |
403 | self.key = key | |
404 | ||
405 | def authdata(self): | |
406 | return {"kid": self.uri} | |
407 | ||
408 | def sign(self, data): | |
0756cac4 FT |
409 | from cryptography.hazmat.primitives import hashes |
410 | from cryptography.hazmat.primitives.asymmetric import padding | |
411 | return self.key.sign(data, padding.PKCS1v15(), hashes.SHA256()) | |
61d08fc2 FT |
412 | |
413 | def getinfo(self): | |
414 | data, headers = jreq(self.uri, None, self) | |
415 | return data | |
416 | ||
417 | def validate(self): | |
418 | data = self.getinfo() | |
419 | if data.get("status", "") != "valid": | |
420 | raise Exception("account is not valid: %s" % (data.get("status", "\"\""))) | |
421 | ||
422 | def write(self, out): | |
0756cac4 | 423 | from cryptography.hazmat.primitives import serialization |
61d08fc2 | 424 | out.write("%s\n" % (self.uri,)) |
0756cac4 FT |
425 | out.write("%s\n" % (self.key.private_bytes( |
426 | encoding=serialization.Encoding.PEM, | |
427 | format=serialization.PrivateFormat.TraditionalOpenSSL, | |
428 | encryption_algorithm=serialization.NoEncryption() | |
429 | ).decode("us-ascii"),)) | |
61d08fc2 FT |
430 | |
431 | @classmethod | |
432 | def read(cls, fp): | |
0756cac4 | 433 | from cryptography.hazmat.primitives import serialization |
61d08fc2 FT |
434 | uri = fp.readline() |
435 | if uri == "": | |
436 | raise Exception("missing account URI") | |
437 | uri = uri.strip() | |
0756cac4 | 438 | key = serialization.load_pem_private_key(fp.read().encode("us-ascii"), password=None, backend=cryptobke()) |
61d08fc2 FT |
439 | return cls(uri, key) |
440 | ||
f1b49ff6 | 441 | ### ACME protocol |
61d08fc2 | 442 | |
f1b49ff6 FT |
443 | service = "https://acme-v02.api.letsencrypt.org/directory" |
444 | _directory = None | |
445 | def directory(): | |
446 | global _directory | |
447 | if _directory is None: | |
448 | with req(service) as resp: | |
1995d63b | 449 | _directory = json.loads(resp.read().decode("utf-8")) |
f1b49ff6 | 450 | return _directory |
61d08fc2 FT |
451 | |
452 | def register(keysize=4096): | |
0756cac4 FT |
453 | from cryptography.hazmat.primitives.asymmetric import rsa |
454 | key = rsa.generate_private_key(public_exponent=65537, key_size=keysize, backend=cryptobke()) | |
61d08fc2 FT |
455 | data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key)) |
456 | return account(headers["Location"], key) | |
457 | ||
458 | def mkorder(acct, csr): | |
459 | data, headers = jreq(directory()["newOrder"], {"identifiers": [{"type": "dns", "value": dn} for dn in csr.domains()]}, acct) | |
460 | data["acmecert.location"] = headers["Location"] | |
461 | return data | |
462 | ||
463 | def httptoken(acct, ch): | |
0756cac4 | 464 | from cryptography.hazmat.primitives import hashes |
7f1c64ed FT |
465 | pub = acct.key.public_key().public_numbers() |
466 | jwk = {"kty": "RSA", "e": ebignum(pub.e), "n": ebignum(pub.n)} | |
467 | dig = hashes.Hash(hashes.SHA256(), backend=cryptobke()) | |
61d08fc2 | 468 | dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii")) |
0756cac4 | 469 | khash = base64url(dig.finalize()) |
61d08fc2 FT |
470 | return ch["token"], ("%s.%s" % (ch["token"], khash)) |
471 | ||
f1b49ff6 FT |
472 | def finalize(acct, csr, orderid): |
473 | order, headers = jreq(orderid, None, acct) | |
474 | if order["status"] == "valid": | |
475 | pass | |
476 | elif order["status"] == "ready": | |
477 | jreq(order["finalize"], {"csr": base64url(csr.der())}, acct) | |
478 | for n in range(30): | |
479 | resp, headers = jreq(orderid, None, acct) | |
480 | if resp["status"] == "processing": | |
481 | time.sleep(2) | |
482 | elif resp["status"] == "valid": | |
483 | order = resp | |
484 | break | |
485 | else: | |
486 | raise Exception("unexpected order status when finalizing: %s" % resp["status"]) | |
487 | else: | |
488 | raise Exception("order finalization timed out") | |
489 | else: | |
490 | raise Exception("unexpected order state when finalizing: %s" % (order["status"],)) | |
491 | with req(order["certificate"]) as resp: | |
492 | return resp.read().decode("us-ascii") | |
493 | ||
494 | ## http-01 challenge | |
495 | ||
496 | class htconfig(object): | |
497 | def __init__(self): | |
498 | self.roots = {} | |
499 | ||
500 | @classmethod | |
501 | def read(cls, fp): | |
502 | self = cls() | |
503 | for ln in fp: | |
504 | words = ln.split() | |
505 | if len(words) < 1 or ln[0] == '#': | |
506 | continue | |
507 | if words[0] == "root": | |
508 | self.roots[words[1]] = words[2] | |
509 | else: | |
510 | sys.stderr.write("acmecert: warning: unknown htconfig directive: %s\n" % (words[0])) | |
511 | return self | |
512 | ||
61d08fc2 FT |
513 | def authorder(acct, htconf, orderid): |
514 | order, headers = jreq(orderid, None, acct) | |
515 | valid = False | |
516 | tries = 0 | |
517 | while not valid: | |
518 | valid = True | |
519 | tries += 1 | |
520 | if tries > 5: | |
521 | raise Exception("challenges refuse to become valid even after 5 retries") | |
522 | for authuri in order["authorizations"]: | |
523 | auth, headers = jreq(authuri, None, acct) | |
524 | if auth["status"] == "valid": | |
525 | continue | |
526 | elif auth["status"] == "pending": | |
527 | pass | |
528 | else: | |
529 | raise Exception("unknown authorization status: %s" % (auth["status"],)) | |
530 | valid = False | |
531 | if auth["identifier"]["type"] != "dns": | |
532 | raise Exception("unknown authorization type: %s" % (auth["identifier"]["type"],)) | |
533 | dn = auth["identifier"]["value"] | |
534 | if dn not in htconf.roots: | |
535 | raise Exception("no configured ht-root for domain name %s" % (dn,)) | |
536 | for ch in auth["challenges"]: | |
537 | if ch["type"] == "http-01": | |
538 | break | |
539 | else: | |
540 | raise Exception("no http-01 challenge for %s" % (dn,)) | |
541 | root = htconf.roots[dn] | |
542 | tokid, tokval = httptoken(acct, ch) | |
543 | tokpath = os.path.join(root, tokid); | |
544 | fp = open(tokpath, "w") | |
545 | try: | |
546 | with fp: | |
547 | fp.write(tokval) | |
548 | with req("http://%s/.well-known/acme-challenge/%s" % (dn, tokid)) as resp: | |
549 | if resp.read().decode("utf-8") != tokval: | |
550 | raise Exception("challenge from %s does not match written value" % (dn,)) | |
551 | for n in range(30): | |
552 | resp, headers = jreq(ch["url"], {}, acct) | |
553 | if resp["status"] == "processing": | |
554 | time.sleep(2) | |
db705a3b FT |
555 | elif resp["status"] == "pending": |
556 | # I don't think this should happen, but it | |
557 | # does. LE bug? Anyway, just retry. | |
62b251ca FT |
558 | if n < 5: |
559 | time.sleep(2) | |
560 | else: | |
561 | break | |
61d08fc2 FT |
562 | elif resp["status"] == "valid": |
563 | break | |
564 | else: | |
565 | raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"])) | |
566 | else: | |
567 | raise Exception("challenge processing timed out for %s" % (dn,)) | |
568 | finally: | |
569 | os.unlink(tokpath) | |
570 | ||
f1b49ff6 | 571 | ### Invocation and commands |
cc8619b5 | 572 | |
28d5a321 | 573 | invdata = threading.local() |
cc8619b5 FT |
574 | commands = {} |
575 | ||
28d5a321 FT |
576 | class usageerr(msgerror): |
577 | def __init__(self): | |
578 | self.cmd = invdata.cmd | |
579 | ||
580 | def report(self, out): | |
581 | out.write("%s\n" % (self.cmd.__doc__,)) | |
582 | ||
f1b49ff6 FT |
583 | ## User commands |
584 | ||
cc8619b5 FT |
585 | def cmd_reg(args): |
586 | "usage: acmecert reg [OUTPUT-FILE]" | |
587 | acct = register() | |
bfe6116d | 588 | os.umask(0o077) |
cc8619b5 FT |
589 | with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp: |
590 | acct.write(fp) | |
591 | commands["reg"] = cmd_reg | |
592 | ||
593 | def cmd_validate_acct(args): | |
594 | "usage: acmecert validate-acct ACCOUNT-FILE" | |
595 | if len(args) < 2: raise usageerr() | |
596 | with maybeopen(args[1], "r") as fp: | |
40a14578 | 597 | account.read(fp).validate() |
cc8619b5 FT |
598 | commands["validate-acct"] = cmd_validate_acct |
599 | ||
600 | def cmd_acct_info(args): | |
601 | "usage: acmecert acct-info ACCOUNT-FILE" | |
602 | if len(args) < 2: raise usageerr() | |
603 | with maybeopen(args[1], "r") as fp: | |
604 | pprint.pprint(account.read(fp).getinfo()) | |
9cef04aa | 605 | commands["acct-info"] = cmd_acct_info |
cc8619b5 FT |
606 | |
607 | def cmd_order(args): | |
608 | "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]" | |
8cea2234 | 609 | if len(args) < 3: raise usageerr() |
cc8619b5 FT |
610 | with maybeopen(args[1], "r") as fp: |
611 | acct = account.read(fp) | |
612 | with maybeopen(args[2], "r") as fp: | |
613 | csr = signreq.read(fp) | |
614 | order = mkorder(acct, csr) | |
615 | with maybeopen(args[3] if len(args) > 3 else "-", "w") as fp: | |
616 | fp.write("%s\n" % (order["acmecert.location"])) | |
617 | commands["order"] = cmd_order | |
618 | ||
619 | def cmd_http_auth(args): | |
620 | "usage: acmecert http-auth ACCOUNT-FILE HTTP-CONFIG {ORDER-ID|ORDER-FILE}" | |
621 | if len(args) < 4: raise usageerr() | |
622 | with maybeopen(args[1], "r") as fp: | |
623 | acct = account.read(fp) | |
624 | with maybeopen(args[2], "r") as fp: | |
625 | htconf = htconfig.read(fp) | |
626 | if "://" in args[3]: | |
627 | orderid = args[3] | |
628 | else: | |
629 | with maybeopen(args[3], "r") as fp: | |
630 | orderid = fp.readline().strip() | |
631 | authorder(acct, htconf, orderid) | |
632 | commands["http-auth"] = cmd_http_auth | |
633 | ||
634 | def cmd_get(args): | |
635 | "usage: acmecert get ACCOUNT-FILE CSR {ORDER-ID|ORDER-FILE}" | |
636 | if len(args) < 4: raise usageerr() | |
637 | with maybeopen(args[1], "r") as fp: | |
638 | acct = account.read(fp) | |
639 | with maybeopen(args[2], "r") as fp: | |
640 | csr = signreq.read(fp) | |
641 | if "://" in args[3]: | |
642 | orderid = args[3] | |
643 | else: | |
644 | with maybeopen(args[3], "r") as fp: | |
645 | orderid = fp.readline().strip() | |
646 | sys.stdout.write(finalize(acct, csr, orderid)) | |
647 | commands["get"] = cmd_get | |
648 | ||
649 | def cmd_http_order(args): | |
650 | "usage: acmecert http-order ACCOUNT-FILE CSR HTTP-CONFIG [OUTPUT-FILE]" | |
651 | if len(args) < 4: raise usageerr() | |
652 | with maybeopen(args[1], "r") as fp: | |
653 | acct = account.read(fp) | |
654 | with maybeopen(args[2], "r") as fp: | |
655 | csr = signreq.read(fp) | |
656 | with maybeopen(args[3], "r") as fp: | |
657 | htconf = htconfig.read(fp) | |
658 | orderid = mkorder(acct, csr)["acmecert.location"] | |
659 | authorder(acct, htconf, orderid) | |
660 | with maybeopen(args[4] if len(args) > 4 else "-", "w") as fp: | |
661 | fp.write(finalize(acct, csr, orderid)) | |
662 | commands["http-order"] = cmd_http_order | |
663 | ||
664 | def cmd_check_cert(args): | |
665 | "usage: acmecert check-cert CERT-FILE TIME-SPEC" | |
666 | if len(args) < 3: raise usageerr() | |
667 | with maybeopen(args[1], "r") as fp: | |
668 | crt = certificate.read(fp) | |
669 | sys.exit(1 if crt.expiring(args[2]) else 0) | |
670 | commands["check-cert"] = cmd_check_cert | |
671 | ||
672 | def cmd_directory(args): | |
673 | "usage: acmecert directory" | |
674 | pprint.pprint(directory()) | |
675 | commands["directory"] = cmd_directory | |
676 | ||
f1b49ff6 FT |
677 | ## Main invocation |
678 | ||
61d08fc2 | 679 | def usage(out): |
cc8619b5 FT |
680 | out.write("usage: acmecert [-D SERVICE] COMMAND [ARGS...]\n") |
681 | out.write(" acmecert -h [COMMAND]\n") | |
682 | buf = " COMMAND is any of: " | |
683 | f = True | |
684 | for cmd in commands: | |
685 | if len(buf) + len(cmd) > 70: | |
686 | out.write("%s\n" % (buf,)) | |
687 | buf = " " | |
688 | f = True | |
689 | if not f: | |
690 | buf += ", " | |
691 | buf += cmd | |
692 | f = False | |
693 | if not f: | |
694 | out.write("%s\n" % (buf,)) | |
61d08fc2 FT |
695 | |
696 | def main(argv): | |
697 | global service | |
698 | opts, args = getopt.getopt(argv[1:], "hD:") | |
699 | for o, a in opts: | |
700 | if o == "-h": | |
cc8619b5 FT |
701 | if len(args) > 0: |
702 | cmd = commands.get(args[0]) | |
703 | if cmd is None: | |
704 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) | |
705 | sys.exit(1) | |
706 | sys.stdout.write("%s\n" % (cmd.__doc__,)) | |
707 | else: | |
708 | usage(sys.stdout) | |
61d08fc2 FT |
709 | sys.exit(0) |
710 | elif o == "-D": | |
711 | service = a | |
712 | if len(args) < 1: | |
713 | usage(sys.stderr) | |
714 | sys.exit(1) | |
cc8619b5 FT |
715 | cmd = commands.get(args[0]) |
716 | if cmd is None: | |
61d08fc2 FT |
717 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) |
718 | usage(sys.stderr) | |
719 | sys.exit(1) | |
cc8619b5 | 720 | try: |
28d5a321 FT |
721 | try: |
722 | invdata.cmd = cmd | |
723 | cmd(args) | |
724 | finally: | |
725 | invdata.cmd = None | |
726 | except msgerror as exc: | |
727 | exc.report(sys.stderr) | |
cc8619b5 | 728 | sys.exit(1) |
61d08fc2 FT |
729 | |
730 | if __name__ == "__main__": | |
731 | try: | |
732 | main(sys.argv) | |
733 | except KeyboardInterrupt: | |
734 | signal.signal(signal.SIGINT, signal.SIG_DFL) | |
735 | os.kill(os.getpid(), signal.SIGINT) |