| 1 | #!/usr/bin/python3 |
| 2 | |
| 3 | import sys, os, getopt, binascii, json, pprint, signal, time |
| 4 | import urllib.request |
| 5 | import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5 |
| 6 | |
| 7 | service = "https://acme-v02.api.letsencrypt.org/directory" |
| 8 | _directory = None |
| 9 | def directory(): |
| 10 | global _directory |
| 11 | if _directory is None: |
| 12 | with req(service) as resp: |
| 13 | _directory = json.loads(resp.read().decode("utf-8")) |
| 14 | return _directory |
| 15 | |
| 16 | def base64url(dat): |
| 17 | return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip() |
| 18 | |
| 19 | def ebignum(num): |
| 20 | h = "%x" % num |
| 21 | if len(h) % 2 == 1: h = "0" + h |
| 22 | return base64url(binascii.a2b_hex(h)) |
| 23 | |
| 24 | def getnonce(): |
| 25 | with urllib.request.urlopen(directory()["newNonce"]) as resp: |
| 26 | resp.read() |
| 27 | return resp.headers["Replay-Nonce"] |
| 28 | |
| 29 | def req(url, data=None, ctype=None, headers={}, method=None, **kws): |
| 30 | if data is not None and not isinstance(data, bytes): |
| 31 | data = json.dumps(data).encode("utf-8") |
| 32 | ctype = "application/jose+json" |
| 33 | req = urllib.request.Request(url, data=data, method=method) |
| 34 | for hnam, hval in headers.items(): |
| 35 | req.add_header(hnam, hval) |
| 36 | if ctype is not None: |
| 37 | req.add_header("Content-Type", ctype) |
| 38 | return urllib.request.urlopen(req) |
| 39 | |
| 40 | def jreq(url, data, auth): |
| 41 | authdata = {"alg": "RS256", "url": url, "nonce": getnonce()} |
| 42 | authdata.update(auth.authdata()) |
| 43 | authdata = base64url(json.dumps(authdata).encode("us-ascii")) |
| 44 | if data is None: |
| 45 | data = "" |
| 46 | else: |
| 47 | data = base64url(json.dumps(data).encode("us-ascii")) |
| 48 | seal = base64url(auth.sign(("%s.%s" % (authdata, data)).encode("us-ascii"))) |
| 49 | enc = {"protected": authdata, "payload": data, "signature": seal} |
| 50 | with req(url, data=enc) as resp: |
| 51 | return json.loads(resp.read().decode("utf-8")), resp.headers |
| 52 | |
| 53 | class signreq(object): |
| 54 | def domains(self): |
| 55 | # No PCKS10 parser for Python? |
| 56 | import subprocess, re |
| 57 | with subprocess.Popen(["openssl", "req", "-noout", "-text"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: |
| 58 | openssl.stdin.write(self.data.encode("us-ascii")) |
| 59 | openssl.stdin.close() |
| 60 | resp = openssl.stdout.read().decode("utf8") |
| 61 | if openssl.wait() != 0: |
| 62 | raise Exception("openssl error") |
| 63 | m = re.search(r"X509v3 Subject Alternative Name:[^\n]*\n\s*((\w+:\S+,\s*)*\w+:\S+)\s*\n", resp) |
| 64 | if m is None: |
| 65 | return [] |
| 66 | ret = [] |
| 67 | for nm in m.group(1).split(","): |
| 68 | nm = nm.strip() |
| 69 | typ, nm = nm.split(":", 1) |
| 70 | if typ == "DNS": |
| 71 | ret.append(nm) |
| 72 | return ret |
| 73 | |
| 74 | def der(self): |
| 75 | import subprocess |
| 76 | with subprocess.Popen(["openssl", "req", "-outform", "der"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: |
| 77 | openssl.stdin.write(self.data.encode("us-ascii")) |
| 78 | openssl.stdin.close() |
| 79 | resp = openssl.stdout.read() |
| 80 | if openssl.wait() != 0: |
| 81 | raise Exception("openssl error") |
| 82 | return resp |
| 83 | |
| 84 | @classmethod |
| 85 | def read(cls, fp): |
| 86 | self = cls() |
| 87 | self.data = fp.read() |
| 88 | return self |
| 89 | |
| 90 | class jwkauth(object): |
| 91 | def __init__(self, key): |
| 92 | self.key = key |
| 93 | |
| 94 | def authdata(self): |
| 95 | return {"jwk": {"kty": "RSA", "e": ebignum(self.key.e), "n": ebignum(self.key.n)}} |
| 96 | |
| 97 | def sign(self, data): |
| 98 | dig = Crypto.Hash.SHA256.new() |
| 99 | dig.update(data) |
| 100 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) |
| 101 | |
| 102 | class account(object): |
| 103 | def __init__(self, uri, key): |
| 104 | self.uri = uri |
| 105 | self.key = key |
| 106 | |
| 107 | def authdata(self): |
| 108 | return {"kid": self.uri} |
| 109 | |
| 110 | def sign(self, data): |
| 111 | dig = Crypto.Hash.SHA256.new() |
| 112 | dig.update(data) |
| 113 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) |
| 114 | |
| 115 | def getinfo(self): |
| 116 | data, headers = jreq(self.uri, None, self) |
| 117 | return data |
| 118 | |
| 119 | def validate(self): |
| 120 | data = self.getinfo() |
| 121 | if data.get("status", "") != "valid": |
| 122 | raise Exception("account is not valid: %s" % (data.get("status", "\"\""))) |
| 123 | |
| 124 | def write(self, out): |
| 125 | out.write("%s\n" % (self.uri,)) |
| 126 | out.write("%s\n" % (self.key.exportKey().decode("us-ascii"),)) |
| 127 | |
| 128 | @classmethod |
| 129 | def read(cls, fp): |
| 130 | uri = fp.readline() |
| 131 | if uri == "": |
| 132 | raise Exception("missing account URI") |
| 133 | uri = uri.strip() |
| 134 | key = Crypto.PublicKey.RSA.importKey(fp.read()) |
| 135 | return cls(uri, key) |
| 136 | |
| 137 | class htconfig(object): |
| 138 | def __init__(self): |
| 139 | self.roots = {} |
| 140 | |
| 141 | @classmethod |
| 142 | def read(cls, fp): |
| 143 | self = cls() |
| 144 | for ln in fp: |
| 145 | words = ln.split() |
| 146 | if len(words) < 1 or ln[0] == '#': |
| 147 | continue |
| 148 | if words[0] == "root": |
| 149 | self.roots[words[1]] = words[2] |
| 150 | else: |
| 151 | sys.stderr.write("acmecert: warning: unknown htconfig directive: %s\n" % (words[0])) |
| 152 | return self |
| 153 | |
| 154 | def register(keysize=4096): |
| 155 | key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read) |
| 156 | # jwk = {"kty": "RSA", "e": ebignum(key.e), "n": ebignum(key.n)} |
| 157 | # cjwk = json.dumps(jwk, separators=(',', ':'), sort_keys=True) |
| 158 | data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key)) |
| 159 | return account(headers["Location"], key) |
| 160 | |
| 161 | def mkorder(acct, csr): |
| 162 | data, headers = jreq(directory()["newOrder"], {"identifiers": [{"type": "dns", "value": dn} for dn in csr.domains()]}, acct) |
| 163 | data["acmecert.location"] = headers["Location"] |
| 164 | return data |
| 165 | |
| 166 | def httptoken(acct, ch): |
| 167 | jwk = {"kty": "RSA", "e": ebignum(acct.key.e), "n": ebignum(acct.key.n)} |
| 168 | dig = Crypto.Hash.SHA256.new() |
| 169 | dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii")) |
| 170 | khash = base64url(dig.digest()) |
| 171 | return ch["token"], ("%s.%s" % (ch["token"], khash)) |
| 172 | |
| 173 | def authorder(acct, htconf, orderid): |
| 174 | order, headers = jreq(orderid, None, acct) |
| 175 | valid = False |
| 176 | tries = 0 |
| 177 | while not valid: |
| 178 | valid = True |
| 179 | tries += 1 |
| 180 | if tries > 5: |
| 181 | raise Exception("challenges refuse to become valid even after 5 retries") |
| 182 | for authuri in order["authorizations"]: |
| 183 | auth, headers = jreq(authuri, None, acct) |
| 184 | if auth["status"] == "valid": |
| 185 | continue |
| 186 | elif auth["status"] == "pending": |
| 187 | pass |
| 188 | else: |
| 189 | raise Exception("unknown authorization status: %s" % (auth["status"],)) |
| 190 | valid = False |
| 191 | if auth["identifier"]["type"] != "dns": |
| 192 | raise Exception("unknown authorization type: %s" % (auth["identifier"]["type"],)) |
| 193 | dn = auth["identifier"]["value"] |
| 194 | if dn not in htconf.roots: |
| 195 | raise Exception("no configured ht-root for domain name %s" % (dn,)) |
| 196 | for ch in auth["challenges"]: |
| 197 | if ch["type"] == "http-01": |
| 198 | break |
| 199 | else: |
| 200 | raise Exception("no http-01 challenge for %s" % (dn,)) |
| 201 | root = htconf.roots[dn] |
| 202 | tokid, tokval = httptoken(acct, ch) |
| 203 | tokpath = os.path.join(root, tokid); |
| 204 | fp = open(tokpath, "w") |
| 205 | try: |
| 206 | with fp: |
| 207 | fp.write(tokval) |
| 208 | with req("http://%s/.well-known/acme-challenge/%s" % (dn, tokid)) as resp: |
| 209 | if resp.read().decode("utf-8") != tokval: |
| 210 | raise Exception("challenge from %s does not match written value" % (dn,)) |
| 211 | for n in range(30): |
| 212 | resp, headers = jreq(ch["url"], {}, acct) |
| 213 | if resp["status"] == "processing": |
| 214 | time.sleep(2) |
| 215 | elif resp["status"] == "valid": |
| 216 | break |
| 217 | else: |
| 218 | raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"])) |
| 219 | else: |
| 220 | raise Exception("challenge processing timed out for %s" % (dn,)) |
| 221 | finally: |
| 222 | os.unlink(tokpath) |
| 223 | |
| 224 | def finalize(acct, csr, orderid): |
| 225 | order, headers = jreq(orderid, None, acct) |
| 226 | if order["status"] == "valid": |
| 227 | pass |
| 228 | elif order["status"] == "ready": |
| 229 | jreq(order["finalize"], {"csr": base64url(csr.der())}, acct) |
| 230 | for n in range(30): |
| 231 | resp, headers = jreq(orderid, None, acct) |
| 232 | if resp["status"] == "processing": |
| 233 | time.sleep(2) |
| 234 | elif resp["status"] == "valid": |
| 235 | order = resp |
| 236 | break |
| 237 | else: |
| 238 | raise Exception("unexpected order status when finalizing: %s" % resp["status"]) |
| 239 | else: |
| 240 | raise Exception("order finalization timed out") |
| 241 | else: |
| 242 | raise Exception("unexpected order state when finalizing: %s" % (order["status"],)) |
| 243 | with req(order["certificate"]) as resp: |
| 244 | return resp.read().decode("us-ascii") |
| 245 | |
| 246 | def usage(out): |
| 247 | out.write("usage: acmecert [-h] [-D SERVICE]\n") |
| 248 | |
| 249 | def main(argv): |
| 250 | global service |
| 251 | opts, args = getopt.getopt(argv[1:], "hD:") |
| 252 | for o, a in opts: |
| 253 | if o == "-h": |
| 254 | usage(sys.stdout) |
| 255 | sys.exit(0) |
| 256 | elif o == "-D": |
| 257 | service = a |
| 258 | if len(args) < 1: |
| 259 | usage(sys.stderr) |
| 260 | sys.exit(1) |
| 261 | if args[0] == "reg": |
| 262 | register().write(sys.stdout) |
| 263 | elif args[0] == "validate-acct": |
| 264 | with open(args[1], "r") as fp: |
| 265 | account.read(fp).validate() |
| 266 | elif args[0] == "acctinfo": |
| 267 | with open(args[1], "r") as fp: |
| 268 | pprint.pprint(account.read(fp).getinfo()) |
| 269 | elif args[0] == "order": |
| 270 | with open(args[1], "r") as fp: |
| 271 | acct = account.read(fp) |
| 272 | with open(args[2], "r") as fp: |
| 273 | csr = signreq.read(fp) |
| 274 | order = mkorder(acct, csr) |
| 275 | with open(args[3], "w") as fp: |
| 276 | fp.write("%s\n" % (order["acmecert.location"])) |
| 277 | elif args[0] == "http-auth": |
| 278 | with open(args[1], "r") as fp: |
| 279 | acct = account.read(fp) |
| 280 | with open(args[2], "r") as fp: |
| 281 | htconf = htconfig.read(fp) |
| 282 | with open(args[3], "r") as fp: |
| 283 | orderid = fp.readline().strip() |
| 284 | authorder(acct, htconf, orderid) |
| 285 | elif args[0] == "get": |
| 286 | with open(args[1], "r") as fp: |
| 287 | acct = account.read(fp) |
| 288 | with open(args[2], "r") as fp: |
| 289 | csr = signreq.read(fp) |
| 290 | with open(args[3], "r") as fp: |
| 291 | orderid = fp.readline().strip() |
| 292 | sys.stdout.write(finalize(acct, csr, orderid)) |
| 293 | elif args[0] == "http-order": |
| 294 | with open(args[1], "r") as fp: |
| 295 | acct = account.read(fp) |
| 296 | with open(args[2], "r") as fp: |
| 297 | csr = signreq.read(fp) |
| 298 | with open(args[3], "r") as fp: |
| 299 | htconf = htconfig.read(fp) |
| 300 | orderid = mkorder(acct, csr)["acmecert.location"] |
| 301 | authorder(acct, htconf, orderid) |
| 302 | sys.stdout.write(finalize(acct, csr, orderid)) |
| 303 | elif args[0] == "directory": |
| 304 | pprint.pprint(directory()) |
| 305 | else: |
| 306 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) |
| 307 | usage(sys.stderr) |
| 308 | sys.exit(1) |
| 309 | |
| 310 | if __name__ == "__main__": |
| 311 | try: |
| 312 | main(sys.argv) |
| 313 | except KeyboardInterrupt: |
| 314 | signal.signal(signal.SIGINT, signal.SIG_DFL) |
| 315 | os.kill(os.getpid(), signal.SIGINT) |