| 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) |