acmecert: Fix cryptography bugs.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
f1b49ff6
FT
3#### ACME client (only http-01 challenges supported thus far)
4
d2252d10 5import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading
61d08fc2 6import urllib.request
61d08fc2 7
f1b49ff6
FT
8### General utilities
9
28d5a321
FT
10class msgerror(Exception):
11 def report(self, out):
12 out.write("acmecert: undefined error\n")
13
61d08fc2
FT
14def base64url(dat):
15 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
16
17def 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
22class 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
47def 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
54class dererror(Exception):
55 pass
56
57class pemerror(Exception):
58 pass
59
60def 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
81class 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 240class 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 270class 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
309def getnonce():
310 with urllib.request.urlopen(directory()["newNonce"]) as resp:
311 resp.read()
312 return resp.headers["Replay-Nonce"]
313
314def 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
325class 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
367def 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
387class 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
400class 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
443service = "https://acme-v02.api.letsencrypt.org/directory"
444_directory = None
445def 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
452def 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
458def 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
463def 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
472def 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
496class 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
513def 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 573invdata = threading.local()
cc8619b5
FT
574commands = {}
575
28d5a321
FT
576class 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
585def 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)
591commands["reg"] = cmd_reg
592
593def 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
598commands["validate-acct"] = cmd_validate_acct
599
600def 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 605commands["acct-info"] = cmd_acct_info
cc8619b5
FT
606
607def 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"]))
617commands["order"] = cmd_order
618
619def 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)
632commands["http-auth"] = cmd_http_auth
633
634def 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))
647commands["get"] = cmd_get
648
649def 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))
662commands["http-order"] = cmd_http_order
663
664def 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)
670commands["check-cert"] = cmd_check_cert
671
672def cmd_directory(args):
673 "usage: acmecert directory"
674 pprint.pprint(directory())
675commands["directory"] = cmd_directory
676
f1b49ff6
FT
677## Main invocation
678
61d08fc2 679def 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
696def 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
730if __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)