acmecert: Initial commit.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
3import sys, os, getopt, binascii, json, pprint, signal, time
4import urllib.request
5import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
6
7service = "https://acme-v02.api.letsencrypt.org/directory"
8_directory = None
9def 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
16def base64url(dat):
17 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
18
19def ebignum(num):
20 h = "%x" % num
21 if len(h) % 2 == 1: h = "0" + h
22 return base64url(binascii.a2b_hex(h))
23
24def getnonce():
25 with urllib.request.urlopen(directory()["newNonce"]) as resp:
26 resp.read()
27 return resp.headers["Replay-Nonce"]
28
29def 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
40def 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
53class 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
90class 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
102class 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
137class 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
154def 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
161def 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
166def 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
173def 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
224def 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
246def usage(out):
247 out.write("usage: acmecert [-h] [-D SERVICE]\n")
248
249def 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
310if __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)