acmecert: Fix cryptography bugs.
[utils.git] / acmecert
1 #!/usr/bin/python3
2
3 #### ACME client (only http-01 challenges supported thus far)
4
5 import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading
6 import urllib.request
7
8 ### General utilities
9
10 class msgerror(Exception):
11     def report(self, out):
12         out.write("acmecert: undefined error\n")
13
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
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)
32         else:
33             self.opened = True
34             self.fp = open(name, mode)
35
36     def __enter__(self):
37         return self.fp
38
39     def __exit__(self, *excinfo):
40         if self.opened:
41             self.fp.close()
42         return False
43
44 ### Crypto utilities
45
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
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
240 class certificate(object):
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()
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):
268         return cls(derdecoder.frompem(fp.read(), {"CERTIFICATE", "X509 CERTIFICATE"}))
269
270 class signreq(object):
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
297     def domains(self):
298         return [nm[1] for nm in self.altnames if nm[0] == "DNS"]
299
300     def der(self):
301         return self.raw
302
303     @classmethod
304     def read(cls, fp):
305         return cls(pemdec(fp.read(), {"CERTIFICATE REQUEST"}))
306
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):
364         self = cls(err.code, json.loads(err.read().decode("utf-8")), **kw)
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:
379             return json.loads(resp.read().decode("utf-8")), resp.headers
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
387 class jwkauth(object):
388     def __init__(self, key):
389         self.key = key
390
391     def authdata(self):
392         pub = self.key.public_key().public_numbers()
393         return {"jwk": {"kty": "RSA", "e": ebignum(pub.e), "n": ebignum(pub.n)}}
394
395     def sign(self, data):
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())
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):
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())
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):
423         from cryptography.hazmat.primitives import serialization
424         out.write("%s\n" % (self.uri,))
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"),))
430
431     @classmethod
432     def read(cls, fp):
433         from cryptography.hazmat.primitives import serialization
434         uri = fp.readline()
435         if uri == "":
436             raise Exception("missing account URI")
437         uri = uri.strip()
438         key = serialization.load_pem_private_key(fp.read().encode("us-ascii"), password=None, backend=cryptobke())
439         return cls(uri, key)
440
441 ### ACME protocol
442
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:
449             _directory = json.loads(resp.read().decode("utf-8"))
450     return _directory
451
452 def register(keysize=4096):
453     from cryptography.hazmat.primitives.asymmetric import rsa
454     key = rsa.generate_private_key(public_exponent=65537, key_size=keysize, backend=cryptobke())
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):
464     from cryptography.hazmat.primitives import hashes
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())
468     dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii"))
469     khash = base64url(dig.finalize())
470     return ch["token"], ("%s.%s" % (ch["token"], khash))
471
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
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)
555                     elif resp["status"] == "pending":
556                         # I don't think this should happen, but it
557                         # does. LE bug? Anyway, just retry.
558                         if n < 5:
559                             time.sleep(2)
560                         else:
561                             break
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
571 ### Invocation and commands
572
573 invdata = threading.local()
574 commands = {}
575
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
583 ## User commands
584
585 def cmd_reg(args):
586     "usage: acmecert reg [OUTPUT-FILE]"
587     acct = register()
588     os.umask(0o077)
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:
597         account.read(fp).validate()
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())
605 commands["acct-info"] = cmd_acct_info
606
607 def cmd_order(args):
608     "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
609     if len(args) < 3: raise usageerr()
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
677 ## Main invocation
678
679 def usage(out):
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,))
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":
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)
709             sys.exit(0)
710         elif o == "-D":
711             service = a
712     if len(args) < 1:
713         usage(sys.stderr)
714         sys.exit(1)
715     cmd = commands.get(args[0])
716     if cmd is None:
717         sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
718         usage(sys.stderr)
719         sys.exit(1)
720     try:
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)
728         sys.exit(1)
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)