Commit | Line | Data |
---|---|---|
61d08fc2 FT |
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 | ||
14a46eff FT |
53 | class certificate(object): |
54 | @property | |
55 | def enddate(self): | |
56 | # No X509 parser for Python? | |
57 | import subprocess, re, calendar | |
58 | with subprocess.Popen(["openssl", "x509", "-noout", "-enddate"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
59 | openssl.stdin.write(self.data.encode("us-ascii")) | |
60 | openssl.stdin.close() | |
61 | resp = openssl.stdout.read().decode("utf-8") | |
62 | if openssl.wait() != 0: | |
63 | raise Exception("openssl error") | |
64 | m = re.search(r"notAfter=(.*)$", resp) | |
65 | if m is None: raise Exception("unexpected openssl reply: %r" % (resp,)) | |
66 | return calendar.timegm(time.strptime(m.group(1), "%b %d %H:%M:%S %Y GMT")) | |
67 | ||
68 | def expiring(self, timespec): | |
69 | if timespec.endswith("y"): | |
70 | timespec = int(timespec[:-1]) * 365 * 86400 | |
71 | elif timespec.endswith("m"): | |
72 | timespec = int(timespec[:-1]) * 30 * 86400 | |
73 | elif timespec.endswith("w"): | |
74 | timespec = int(timespec[:-1]) * 7 * 86400 | |
75 | elif timespec.endswith("d"): | |
76 | timespec = int(timespec[:-1]) * 86400 | |
77 | elif timespec.endswith("h"): | |
78 | timespec = int(timespec[:-1]) * 3600 | |
79 | else: | |
80 | timespec = int(timespec) | |
81 | return (self.enddate - time.time()) < timespec | |
82 | ||
83 | @classmethod | |
84 | def read(cls, fp): | |
85 | self = cls() | |
86 | self.data = fp.read() | |
87 | return self | |
88 | ||
61d08fc2 FT |
89 | class signreq(object): |
90 | def domains(self): | |
91 | # No PCKS10 parser for Python? | |
92 | import subprocess, re | |
93 | with subprocess.Popen(["openssl", "req", "-noout", "-text"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
94 | openssl.stdin.write(self.data.encode("us-ascii")) | |
95 | openssl.stdin.close() | |
14a46eff | 96 | resp = openssl.stdout.read().decode("utf-8") |
61d08fc2 FT |
97 | if openssl.wait() != 0: |
98 | raise Exception("openssl error") | |
99 | m = re.search(r"X509v3 Subject Alternative Name:[^\n]*\n\s*((\w+:\S+,\s*)*\w+:\S+)\s*\n", resp) | |
100 | if m is None: | |
101 | return [] | |
102 | ret = [] | |
103 | for nm in m.group(1).split(","): | |
104 | nm = nm.strip() | |
105 | typ, nm = nm.split(":", 1) | |
106 | if typ == "DNS": | |
107 | ret.append(nm) | |
108 | return ret | |
109 | ||
110 | def der(self): | |
111 | import subprocess | |
112 | with subprocess.Popen(["openssl", "req", "-outform", "der"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as openssl: | |
113 | openssl.stdin.write(self.data.encode("us-ascii")) | |
114 | openssl.stdin.close() | |
115 | resp = openssl.stdout.read() | |
116 | if openssl.wait() != 0: | |
117 | raise Exception("openssl error") | |
118 | return resp | |
119 | ||
120 | @classmethod | |
121 | def read(cls, fp): | |
122 | self = cls() | |
123 | self.data = fp.read() | |
124 | return self | |
125 | ||
126 | class jwkauth(object): | |
127 | def __init__(self, key): | |
128 | self.key = key | |
129 | ||
130 | def authdata(self): | |
131 | return {"jwk": {"kty": "RSA", "e": ebignum(self.key.e), "n": ebignum(self.key.n)}} | |
132 | ||
133 | def sign(self, data): | |
134 | dig = Crypto.Hash.SHA256.new() | |
135 | dig.update(data) | |
136 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
137 | ||
138 | class account(object): | |
139 | def __init__(self, uri, key): | |
140 | self.uri = uri | |
141 | self.key = key | |
142 | ||
143 | def authdata(self): | |
144 | return {"kid": self.uri} | |
145 | ||
146 | def sign(self, data): | |
147 | dig = Crypto.Hash.SHA256.new() | |
148 | dig.update(data) | |
149 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
150 | ||
151 | def getinfo(self): | |
152 | data, headers = jreq(self.uri, None, self) | |
153 | return data | |
154 | ||
155 | def validate(self): | |
156 | data = self.getinfo() | |
157 | if data.get("status", "") != "valid": | |
158 | raise Exception("account is not valid: %s" % (data.get("status", "\"\""))) | |
159 | ||
160 | def write(self, out): | |
161 | out.write("%s\n" % (self.uri,)) | |
162 | out.write("%s\n" % (self.key.exportKey().decode("us-ascii"),)) | |
163 | ||
164 | @classmethod | |
165 | def read(cls, fp): | |
166 | uri = fp.readline() | |
167 | if uri == "": | |
168 | raise Exception("missing account URI") | |
169 | uri = uri.strip() | |
170 | key = Crypto.PublicKey.RSA.importKey(fp.read()) | |
171 | return cls(uri, key) | |
172 | ||
173 | class htconfig(object): | |
174 | def __init__(self): | |
175 | self.roots = {} | |
176 | ||
177 | @classmethod | |
178 | def read(cls, fp): | |
179 | self = cls() | |
180 | for ln in fp: | |
181 | words = ln.split() | |
182 | if len(words) < 1 or ln[0] == '#': | |
183 | continue | |
184 | if words[0] == "root": | |
185 | self.roots[words[1]] = words[2] | |
186 | else: | |
187 | sys.stderr.write("acmecert: warning: unknown htconfig directive: %s\n" % (words[0])) | |
188 | return self | |
189 | ||
190 | def register(keysize=4096): | |
191 | key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read) | |
192 | # jwk = {"kty": "RSA", "e": ebignum(key.e), "n": ebignum(key.n)} | |
193 | # cjwk = json.dumps(jwk, separators=(',', ':'), sort_keys=True) | |
194 | data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key)) | |
195 | return account(headers["Location"], key) | |
196 | ||
197 | def mkorder(acct, csr): | |
198 | data, headers = jreq(directory()["newOrder"], {"identifiers": [{"type": "dns", "value": dn} for dn in csr.domains()]}, acct) | |
199 | data["acmecert.location"] = headers["Location"] | |
200 | return data | |
201 | ||
202 | def httptoken(acct, ch): | |
203 | jwk = {"kty": "RSA", "e": ebignum(acct.key.e), "n": ebignum(acct.key.n)} | |
204 | dig = Crypto.Hash.SHA256.new() | |
205 | dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii")) | |
206 | khash = base64url(dig.digest()) | |
207 | return ch["token"], ("%s.%s" % (ch["token"], khash)) | |
208 | ||
209 | def authorder(acct, htconf, orderid): | |
210 | order, headers = jreq(orderid, None, acct) | |
211 | valid = False | |
212 | tries = 0 | |
213 | while not valid: | |
214 | valid = True | |
215 | tries += 1 | |
216 | if tries > 5: | |
217 | raise Exception("challenges refuse to become valid even after 5 retries") | |
218 | for authuri in order["authorizations"]: | |
219 | auth, headers = jreq(authuri, None, acct) | |
220 | if auth["status"] == "valid": | |
221 | continue | |
222 | elif auth["status"] == "pending": | |
223 | pass | |
224 | else: | |
225 | raise Exception("unknown authorization status: %s" % (auth["status"],)) | |
226 | valid = False | |
227 | if auth["identifier"]["type"] != "dns": | |
228 | raise Exception("unknown authorization type: %s" % (auth["identifier"]["type"],)) | |
229 | dn = auth["identifier"]["value"] | |
230 | if dn not in htconf.roots: | |
231 | raise Exception("no configured ht-root for domain name %s" % (dn,)) | |
232 | for ch in auth["challenges"]: | |
233 | if ch["type"] == "http-01": | |
234 | break | |
235 | else: | |
236 | raise Exception("no http-01 challenge for %s" % (dn,)) | |
237 | root = htconf.roots[dn] | |
238 | tokid, tokval = httptoken(acct, ch) | |
239 | tokpath = os.path.join(root, tokid); | |
240 | fp = open(tokpath, "w") | |
241 | try: | |
242 | with fp: | |
243 | fp.write(tokval) | |
244 | with req("http://%s/.well-known/acme-challenge/%s" % (dn, tokid)) as resp: | |
245 | if resp.read().decode("utf-8") != tokval: | |
246 | raise Exception("challenge from %s does not match written value" % (dn,)) | |
247 | for n in range(30): | |
248 | resp, headers = jreq(ch["url"], {}, acct) | |
249 | if resp["status"] == "processing": | |
250 | time.sleep(2) | |
251 | elif resp["status"] == "valid": | |
252 | break | |
253 | else: | |
254 | raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"])) | |
255 | else: | |
256 | raise Exception("challenge processing timed out for %s" % (dn,)) | |
257 | finally: | |
258 | os.unlink(tokpath) | |
259 | ||
260 | def finalize(acct, csr, orderid): | |
261 | order, headers = jreq(orderid, None, acct) | |
262 | if order["status"] == "valid": | |
263 | pass | |
264 | elif order["status"] == "ready": | |
265 | jreq(order["finalize"], {"csr": base64url(csr.der())}, acct) | |
266 | for n in range(30): | |
267 | resp, headers = jreq(orderid, None, acct) | |
268 | if resp["status"] == "processing": | |
269 | time.sleep(2) | |
270 | elif resp["status"] == "valid": | |
271 | order = resp | |
272 | break | |
273 | else: | |
274 | raise Exception("unexpected order status when finalizing: %s" % resp["status"]) | |
275 | else: | |
276 | raise Exception("order finalization timed out") | |
277 | else: | |
278 | raise Exception("unexpected order state when finalizing: %s" % (order["status"],)) | |
279 | with req(order["certificate"]) as resp: | |
280 | return resp.read().decode("us-ascii") | |
281 | ||
282 | def usage(out): | |
283 | out.write("usage: acmecert [-h] [-D SERVICE]\n") | |
284 | ||
285 | def main(argv): | |
286 | global service | |
287 | opts, args = getopt.getopt(argv[1:], "hD:") | |
288 | for o, a in opts: | |
289 | if o == "-h": | |
290 | usage(sys.stdout) | |
291 | sys.exit(0) | |
292 | elif o == "-D": | |
293 | service = a | |
294 | if len(args) < 1: | |
295 | usage(sys.stderr) | |
296 | sys.exit(1) | |
297 | if args[0] == "reg": | |
298 | register().write(sys.stdout) | |
299 | elif args[0] == "validate-acct": | |
300 | with open(args[1], "r") as fp: | |
301 | account.read(fp).validate() | |
302 | elif args[0] == "acctinfo": | |
303 | with open(args[1], "r") as fp: | |
304 | pprint.pprint(account.read(fp).getinfo()) | |
305 | elif args[0] == "order": | |
306 | with open(args[1], "r") as fp: | |
307 | acct = account.read(fp) | |
308 | with open(args[2], "r") as fp: | |
309 | csr = signreq.read(fp) | |
310 | order = mkorder(acct, csr) | |
311 | with open(args[3], "w") as fp: | |
312 | fp.write("%s\n" % (order["acmecert.location"])) | |
313 | elif args[0] == "http-auth": | |
314 | with open(args[1], "r") as fp: | |
315 | acct = account.read(fp) | |
316 | with open(args[2], "r") as fp: | |
317 | htconf = htconfig.read(fp) | |
318 | with open(args[3], "r") as fp: | |
319 | orderid = fp.readline().strip() | |
320 | authorder(acct, htconf, orderid) | |
321 | elif args[0] == "get": | |
322 | with open(args[1], "r") as fp: | |
323 | acct = account.read(fp) | |
324 | with open(args[2], "r") as fp: | |
325 | csr = signreq.read(fp) | |
326 | with open(args[3], "r") as fp: | |
327 | orderid = fp.readline().strip() | |
328 | sys.stdout.write(finalize(acct, csr, orderid)) | |
329 | elif args[0] == "http-order": | |
330 | with open(args[1], "r") as fp: | |
331 | acct = account.read(fp) | |
332 | with open(args[2], "r") as fp: | |
333 | csr = signreq.read(fp) | |
334 | with open(args[3], "r") as fp: | |
335 | htconf = htconfig.read(fp) | |
336 | orderid = mkorder(acct, csr)["acmecert.location"] | |
337 | authorder(acct, htconf, orderid) | |
338 | sys.stdout.write(finalize(acct, csr, orderid)) | |
14a46eff FT |
339 | elif args[0] == "check-cert": |
340 | with open(args[1], "r") as fp: | |
341 | crt = certificate.read(fp) | |
342 | sys.exit(1 if crt.expiring(args[2]) else 0) | |
61d08fc2 FT |
343 | elif args[0] == "directory": |
344 | pprint.pprint(directory()) | |
345 | else: | |
346 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) | |
347 | usage(sys.stderr) | |
348 | sys.exit(1) | |
349 | ||
350 | if __name__ == "__main__": | |
351 | try: | |
352 | main(sys.argv) | |
353 | except KeyboardInterrupt: | |
354 | signal.signal(signal.SIGINT, signal.SIG_DFL) | |
355 | os.kill(os.getpid(), signal.SIGINT) |