OLD | NEW |
(Empty) | |
| 1 import functools |
| 2 import os |
| 3 import shutil |
| 4 import subprocess |
| 5 import tempfile |
| 6 from datetime import datetime |
| 7 |
| 8 class OpenSSL(object): |
| 9 def __init__(self, logger, binary, base_path, conf_path, hosts, duration, |
| 10 base_conf_path=None): |
| 11 """Context manager for interacting with OpenSSL. |
| 12 Creates a config file for the duration of the context. |
| 13 |
| 14 :param logger: stdlib logger or python structured logger |
| 15 :param binary: path to openssl binary |
| 16 :param base_path: path to directory for storing certificates |
| 17 :param conf_path: path for configuration file storing configuration data |
| 18 :param hosts: list of hosts to include in configuration (or None if not |
| 19 generating host certificates) |
| 20 :param duration: Certificate duration in days""" |
| 21 |
| 22 self.base_path = base_path |
| 23 self.binary = binary |
| 24 self.conf_path = conf_path |
| 25 self.base_conf_path = base_conf_path |
| 26 self.logger = logger |
| 27 self.proc = None |
| 28 self.cmd = [] |
| 29 self.hosts = hosts |
| 30 self.duration = duration |
| 31 |
| 32 def __enter__(self): |
| 33 with open(self.conf_path, "w") as f: |
| 34 f.write(get_config(self.base_path, self.hosts, self.duration)) |
| 35 return self |
| 36 |
| 37 def __exit__(self, *args, **kwargs): |
| 38 os.unlink(self.conf_path) |
| 39 |
| 40 def log(self, line): |
| 41 if hasattr(self.logger, "process_output"): |
| 42 self.logger.process_output(self.proc.pid if self.proc is not None el
se None, |
| 43 line.decode("utf8", "replace"), |
| 44 command=" ".join(self.cmd)) |
| 45 else: |
| 46 self.logger.debug(line) |
| 47 |
| 48 def __call__(self, cmd, *args, **kwargs): |
| 49 """Run a command using OpenSSL in the current context. |
| 50 |
| 51 :param cmd: The openssl subcommand to run |
| 52 :param *args: Additional arguments to pass to the command |
| 53 """ |
| 54 self.cmd = [self.binary, cmd] |
| 55 if cmd != "x509": |
| 56 self.cmd += ["-config", self.conf_path] |
| 57 self.cmd += list(args) |
| 58 |
| 59 env = os.environ.copy() |
| 60 if self.base_conf_path is not None: |
| 61 env["OPENSSL_CONF"] = self.base_conf_path.encode("utf8") |
| 62 |
| 63 self.proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=su
bprocess.STDOUT, |
| 64 env=env) |
| 65 stdout, stderr = self.proc.communicate() |
| 66 self.log(stdout) |
| 67 if self.proc.returncode != 0: |
| 68 raise subprocess.CalledProcessError(self.proc.returncode, self.cmd, |
| 69 output=stdout) |
| 70 |
| 71 self.cmd = [] |
| 72 self.proc = None |
| 73 return stdout |
| 74 |
| 75 |
| 76 def make_subject(common_name, |
| 77 country=None, |
| 78 state=None, |
| 79 locality=None, |
| 80 organization=None, |
| 81 organization_unit=None): |
| 82 args = [("country", "C"), |
| 83 ("state", "ST"), |
| 84 ("locality", "L"), |
| 85 ("organization", "O"), |
| 86 ("organization_unit", "OU"), |
| 87 ("common_name", "CN")] |
| 88 |
| 89 rv = [] |
| 90 |
| 91 for var, key in args: |
| 92 value = locals()[var] |
| 93 if value is not None: |
| 94 rv.append("/%s=%s" % (key, value.replace("/", "\\/"))) |
| 95 |
| 96 return "".join(rv) |
| 97 |
| 98 def make_alt_names(hosts): |
| 99 rv = [] |
| 100 for name in hosts: |
| 101 rv.append("DNS:%s" % name) |
| 102 return ",".join(rv) |
| 103 |
| 104 def get_config(root_dir, hosts, duration=30): |
| 105 if hosts is None: |
| 106 san_line = "" |
| 107 else: |
| 108 san_line = "subjectAltName = %s" % make_alt_names(hosts) |
| 109 |
| 110 if os.path.sep == "\\": |
| 111 # This seems to be needed for the Shining Light OpenSSL on |
| 112 # Windows, at least. |
| 113 root_dir = root_dir.replace("\\", "\\\\") |
| 114 |
| 115 rv = """[ ca ] |
| 116 default_ca = CA_default |
| 117 |
| 118 [ CA_default ] |
| 119 dir = %(root_dir)s |
| 120 certs = $dir |
| 121 new_certs_dir = $certs |
| 122 crl_dir = $dir%(sep)scrl |
| 123 database = $dir%(sep)sindex.txt |
| 124 private_key = $dir%(sep)scakey.pem |
| 125 certificate = $dir%(sep)scacert.pem |
| 126 serial = $dir%(sep)sserial |
| 127 crldir = $dir%(sep)scrl |
| 128 crlnumber = $dir%(sep)scrlnumber |
| 129 crl = $crldir%(sep)scrl.pem |
| 130 RANDFILE = $dir%(sep)sprivate%(sep)s.rand |
| 131 x509_extensions = usr_cert |
| 132 name_opt = ca_default |
| 133 cert_opt = ca_default |
| 134 default_days = %(duration)d |
| 135 default_crl_days = %(duration)d |
| 136 default_md = sha256 |
| 137 preserve = no |
| 138 policy = policy_anything |
| 139 copy_extensions = copy |
| 140 |
| 141 [ policy_anything ] |
| 142 countryName = optional |
| 143 stateOrProvinceName = optional |
| 144 localityName = optional |
| 145 organizationName = optional |
| 146 organizationalUnitName = optional |
| 147 commonName = supplied |
| 148 emailAddress = optional |
| 149 |
| 150 [ req ] |
| 151 default_bits = 2048 |
| 152 default_keyfile = privkey.pem |
| 153 distinguished_name = req_distinguished_name |
| 154 attributes = req_attributes |
| 155 x509_extensions = v3_ca |
| 156 |
| 157 # Passwords for private keys if not present they will be prompted for |
| 158 # input_password = secret |
| 159 # output_password = secret |
| 160 string_mask = utf8only |
| 161 req_extensions = v3_req |
| 162 |
| 163 [ req_distinguished_name ] |
| 164 countryName = Country Name (2 letter code) |
| 165 countryName_default = AU |
| 166 countryName_min = 2 |
| 167 countryName_max = 2 |
| 168 stateOrProvinceName = State or Province Name (full name) |
| 169 stateOrProvinceName_default = |
| 170 localityName = Locality Name (eg, city) |
| 171 0.organizationName = Organization Name |
| 172 0.organizationName_default = Web Platform Tests |
| 173 organizationalUnitName = Organizational Unit Name (eg, section) |
| 174 #organizationalUnitName_default = |
| 175 commonName = Common Name (e.g. server FQDN or YOUR name) |
| 176 commonName_max = 64 |
| 177 emailAddress = Email Address |
| 178 emailAddress_max = 64 |
| 179 |
| 180 [ req_attributes ] |
| 181 |
| 182 [ usr_cert ] |
| 183 basicConstraints=CA:false |
| 184 subjectKeyIdentifier=hash |
| 185 authorityKeyIdentifier=keyid,issuer |
| 186 |
| 187 [ v3_req ] |
| 188 basicConstraints = CA:FALSE |
| 189 keyUsage = nonRepudiation, digitalSignature, keyEncipherment |
| 190 extendedKeyUsage = serverAuth |
| 191 %(san_line)s |
| 192 |
| 193 [ v3_ca ] |
| 194 basicConstraints = CA:true |
| 195 subjectKeyIdentifier=hash |
| 196 authorityKeyIdentifier=keyid:always,issuer:always |
| 197 keyUsage = keyCertSign |
| 198 """ % {"root_dir": root_dir, |
| 199 "san_line": san_line, |
| 200 "duration": duration, |
| 201 "sep": os.path.sep.replace("\\", "\\\\")} |
| 202 |
| 203 return rv |
| 204 |
| 205 class OpenSSLEnvironment(object): |
| 206 ssl_enabled = True |
| 207 |
| 208 def __init__(self, logger, openssl_binary="openssl", base_path=None, |
| 209 password="web-platform-tests", force_regenerate=False, |
| 210 duration=30, base_conf_path=None): |
| 211 """SSL environment that creates a local CA and host certificate using Op
enSSL. |
| 212 |
| 213 By default this will look in base_path for existing certificates that ar
e still |
| 214 valid and only create new certificates if there aren't any. This behavio
ur can |
| 215 be adjusted using the force_regenerate option. |
| 216 |
| 217 :param logger: a stdlib logging compatible logger or mozlog structured l
ogger |
| 218 :param openssl_binary: Path to the OpenSSL binary |
| 219 :param base_path: Path in which certificates will be stored. If None, a
temporary |
| 220 directory will be used and removed when the server shu
ts down |
| 221 :param password: Password to use |
| 222 :param force_regenerate: Always create a new certificate even if one alr
eady exists. |
| 223 """ |
| 224 self.logger = logger |
| 225 |
| 226 self.temporary = False |
| 227 if base_path is None: |
| 228 base_path = tempfile.mkdtemp() |
| 229 self.temporary = True |
| 230 |
| 231 self.base_path = os.path.abspath(base_path) |
| 232 self.password = password |
| 233 self.force_regenerate = force_regenerate |
| 234 self.duration = duration |
| 235 self.base_conf_path = base_conf_path |
| 236 |
| 237 self.path = None |
| 238 self.binary = openssl_binary |
| 239 self.openssl = None |
| 240 |
| 241 self._ca_cert_path = None |
| 242 self._ca_key_path = None |
| 243 self.host_certificates = {} |
| 244 |
| 245 def __enter__(self): |
| 246 if not os.path.exists(self.base_path): |
| 247 os.makedirs(self.base_path) |
| 248 |
| 249 path = functools.partial(os.path.join, self.base_path) |
| 250 |
| 251 with open(path("index.txt"), "w"): |
| 252 pass |
| 253 with open(path("serial"), "w") as f: |
| 254 f.write("01") |
| 255 |
| 256 self.path = path |
| 257 |
| 258 return self |
| 259 |
| 260 def __exit__(self, *args, **kwargs): |
| 261 if self.temporary: |
| 262 shutil.rmtree(self.base_path) |
| 263 |
| 264 def _config_openssl(self, hosts): |
| 265 conf_path = self.path("openssl.cfg") |
| 266 return OpenSSL(self.logger, self.binary, self.base_path, conf_path, host
s, |
| 267 self.duration, self.base_conf_path) |
| 268 |
| 269 def ca_cert_path(self): |
| 270 """Get the path to the CA certificate file, generating a |
| 271 new one if needed""" |
| 272 if self._ca_cert_path is None and not self.force_regenerate: |
| 273 self._load_ca_cert() |
| 274 if self._ca_cert_path is None: |
| 275 self._generate_ca() |
| 276 return self._ca_cert_path |
| 277 |
| 278 def _load_ca_cert(self): |
| 279 key_path = self.path("cakey.pem") |
| 280 cert_path = self.path("cacert.pem") |
| 281 |
| 282 if self.check_key_cert(key_path, cert_path, None): |
| 283 self.logger.info("Using existing CA cert") |
| 284 self._ca_key_path, self._ca_cert_path = key_path, cert_path |
| 285 |
| 286 def check_key_cert(self, key_path, cert_path, hosts): |
| 287 """Check that a key and cert file exist and are valid""" |
| 288 if not os.path.exists(key_path) or not os.path.exists(cert_path): |
| 289 return False |
| 290 |
| 291 with self._config_openssl(hosts) as openssl: |
| 292 end_date_str = openssl("x509", |
| 293 "-noout", |
| 294 "-enddate", |
| 295 "-in", cert_path).split("=", 1)[1].strip() |
| 296 # Not sure if this works in other locales |
| 297 end_date = datetime.strptime(end_date_str, "%b %d %H:%M:%S %Y %Z") |
| 298 # Should have some buffer here e.g. 1 hr |
| 299 if end_date < datetime.now(): |
| 300 return False |
| 301 |
| 302 #TODO: check the key actually signed the cert. |
| 303 return True |
| 304 |
| 305 def _generate_ca(self): |
| 306 path = self.path |
| 307 self.logger.info("Generating new CA in %s" % self.base_path) |
| 308 |
| 309 key_path = path("cakey.pem") |
| 310 req_path = path("careq.pem") |
| 311 cert_path = path("cacert.pem") |
| 312 |
| 313 with self._config_openssl(None) as openssl: |
| 314 openssl("req", |
| 315 "-batch", |
| 316 "-new", |
| 317 "-newkey", "rsa:2048", |
| 318 "-keyout", key_path, |
| 319 "-out", req_path, |
| 320 "-subj", make_subject("web-platform-tests"), |
| 321 "-passout", "pass:%s" % self.password) |
| 322 |
| 323 openssl("ca", |
| 324 "-batch", |
| 325 "-create_serial", |
| 326 "-keyfile", key_path, |
| 327 "-passin", "pass:%s" % self.password, |
| 328 "-selfsign", |
| 329 "-extensions", "v3_ca", |
| 330 "-in", req_path, |
| 331 "-out", cert_path) |
| 332 |
| 333 os.unlink(req_path) |
| 334 |
| 335 self._ca_key_path, self._ca_cert_path = key_path, cert_path |
| 336 |
| 337 def host_cert_path(self, hosts): |
| 338 """Get a tuple of (private key path, certificate path) for a host, |
| 339 generating new ones if necessary. |
| 340 |
| 341 hosts must be a list of all hosts to appear on the certificate, with |
| 342 the primary hostname first.""" |
| 343 hosts = tuple(hosts) |
| 344 if hosts not in self.host_certificates: |
| 345 if not self.force_regenerate: |
| 346 key_cert = self._load_host_cert(hosts) |
| 347 else: |
| 348 key_cert = None |
| 349 if key_cert is None: |
| 350 key, cert = self._generate_host_cert(hosts) |
| 351 else: |
| 352 key, cert = key_cert |
| 353 self.host_certificates[hosts] = key, cert |
| 354 |
| 355 return self.host_certificates[hosts] |
| 356 |
| 357 def _load_host_cert(self, hosts): |
| 358 host = hosts[0] |
| 359 key_path = self.path("%s.key" % host) |
| 360 cert_path = self.path("%s.pem" % host) |
| 361 |
| 362 # TODO: check that this cert was signed by the CA cert |
| 363 if self.check_key_cert(key_path, cert_path, hosts): |
| 364 self.logger.info("Using existing host cert") |
| 365 return key_path, cert_path |
| 366 |
| 367 def _generate_host_cert(self, hosts): |
| 368 host = hosts[0] |
| 369 if self._ca_key_path is None: |
| 370 self._generate_ca() |
| 371 ca_key_path = self._ca_key_path |
| 372 |
| 373 assert os.path.exists(ca_key_path) |
| 374 |
| 375 path = self.path |
| 376 |
| 377 req_path = path("wpt.req") |
| 378 cert_path = path("%s.pem" % host) |
| 379 key_path = path("%s.key" % host) |
| 380 |
| 381 self.logger.info("Generating new host cert") |
| 382 |
| 383 with self._config_openssl(hosts) as openssl: |
| 384 openssl("req", |
| 385 "-batch", |
| 386 "-newkey", "rsa:2048", |
| 387 "-keyout", key_path, |
| 388 "-in", ca_key_path, |
| 389 "-nodes", |
| 390 "-out", req_path) |
| 391 |
| 392 openssl("ca", |
| 393 "-batch", |
| 394 "-in", req_path, |
| 395 "-passin", "pass:%s" % self.password, |
| 396 "-subj", make_subject(host), |
| 397 "-out", cert_path) |
| 398 |
| 399 os.unlink(req_path) |
| 400 |
| 401 return key_path, cert_path |
OLD | NEW |