github.com/letsencrypt/boulder@v0.20251208.0/test/v2_integration.py (about) 1 # -*- coding: utf-8 -*- 2 """ 3 Integration test cases for ACMEv2 as implemented by boulder-wfe2. 4 """ 5 import subprocess 6 import requests 7 import datetime 8 import time 9 import os 10 import json 11 import re 12 13 from cryptography import x509 14 from cryptography.hazmat.backends import default_backend 15 from cryptography.hazmat.primitives.asymmetric import rsa 16 from cryptography.hazmat.primitives import serialization 17 18 import chisel2 19 from helpers import * 20 21 from acme import errors as acme_errors 22 23 from acme.messages import Status, CertificateRequest, Directory, NewRegistration 24 from acme import crypto_util as acme_crypto_util 25 from acme import client as acme_client 26 from acme import messages 27 from acme import challenges 28 from acme import errors 29 30 import josepy 31 32 import tempfile 33 import shutil 34 import atexit 35 import random 36 import string 37 38 import threading 39 from http.server import HTTPServer, BaseHTTPRequestHandler 40 import socketserver 41 import socket 42 43 import challtestsrv 44 challSrv = challtestsrv.ChallTestServer() 45 46 def test_multidomain(): 47 chisel2.auth_and_issue([random_domain(), random_domain()]) 48 49 def test_wildcardmultidomain(): 50 """ 51 Test issuance for a random domain and a random wildcard domain using DNS-01. 52 """ 53 chisel2.auth_and_issue([random_domain(), "*."+random_domain()], chall_type="dns-01") 54 55 def test_http_challenge(): 56 chisel2.auth_and_issue([random_domain(), random_domain()], chall_type="http-01") 57 58 def rand_http_chall(client): 59 d = random_domain() 60 csr_pem = chisel2.make_csr([d]) 61 order = client.new_order(csr_pem) 62 authzs = order.authorizations 63 for a in authzs: 64 for c in a.body.challenges: 65 if isinstance(c.chall, challenges.HTTP01): 66 return d, c.chall 67 raise(Exception("No HTTP-01 challenge found for random domain authz")) 68 69 def check_challenge_dns_err(chalType): 70 """ 71 check_challenge_dns_err tests that performing an ACME challenge of the 72 specified type to a hostname that is configured to return SERVFAIL for all 73 queries produces the correct problem type and detail message. 74 """ 75 client = chisel2.make_client() 76 77 # Create a random domains. 78 d = random_domain() 79 80 # Configure the chall srv to SERVFAIL all queries for that domain. 81 challSrv.add_servfail_response(d) 82 83 # Expect a DNS problem with a detail that matches a regex 84 expectedProbType = "dns" 85 expectedProbRegex = re.compile(r"SERVFAIL looking up (A|AAAA|TXT|CAA) for {0}".format(d)) 86 87 # Try and issue for the domain with the given challenge type. 88 failed = False 89 try: 90 chisel2.auth_and_issue([d], client=client, chall_type=chalType) 91 except acme_errors.ValidationError as e: 92 # Mark that the auth_and_issue failed 93 failed = True 94 # Extract the failed challenge from each failed authorization 95 for authzr in e.failed_authzrs: 96 c = None 97 if chalType == "http-01": 98 c = chisel2.get_chall(authzr, challenges.HTTP01) 99 elif chalType == "dns-01": 100 c = chisel2.get_chall(authzr, challenges.DNS01) 101 else: 102 raise(Exception("Invalid challenge type requested: {0}".format(challType))) 103 104 # The failed challenge's error should match expected 105 error = c.error 106 if error is None or error.typ != "urn:ietf:params:acme:error:{0}".format(expectedProbType): 107 raise(Exception("Expected {0} prob, got {1}".format(expectedProbType, error.typ))) 108 if not expectedProbRegex.search(error.detail): 109 raise(Exception("Prob detail did not match expectedProbRegex, got \"{0}\"".format(error.detail))) 110 finally: 111 challSrv.remove_servfail_response(d) 112 113 # If there was no exception that means something went wrong. The test should fail. 114 if failed is False: 115 raise(Exception("No problem generated issuing for broken DNS identifier")) 116 117 def test_http_challenge_dns_err(): 118 """ 119 test_http_challenge_dns_err tests that a HTTP-01 challenge for a domain 120 with broken DNS produces the correct problem response. 121 """ 122 check_challenge_dns_err("http-01") 123 124 def test_dns_challenge_dns_err(): 125 """ 126 test_dns_challenge_dns_err tests that a DNS-01 challenge for a domain 127 with broken DNS produces the correct problem response. 128 """ 129 check_challenge_dns_err("dns-01") 130 131 def test_http_challenge_broken_redirect(): 132 """ 133 test_http_challenge_broken_redirect tests that a common webserver 134 misconfiguration receives the correct specialized error message when attempting 135 an HTTP-01 challenge. 136 """ 137 client = chisel2.make_client() 138 139 # Create an authz for a random domain and get its HTTP-01 challenge token 140 d, chall = rand_http_chall(client) 141 token = chall.encode("token") 142 143 # Create a broken HTTP redirect similar to a sort we see frequently "in the wild" 144 challengePath = "/.well-known/acme-challenge/{0}".format(token) 145 redirect = "http://{0}.well-known/acme-challenge/bad-bad-bad".format(d) 146 challSrv.add_http_redirect( 147 challengePath, 148 redirect) 149 150 # Expect the specialized error message 151 expectedError = "64.112.117.122: Fetching {0}: Invalid host in redirect target \"{1}.well-known\". Check webserver config for missing '/' in redirect target.".format(redirect, d) 152 153 # NOTE(@cpu): Can't use chisel2.expect_problem here because it doesn't let 154 # us interrogate the detail message easily. 155 try: 156 chisel2.auth_and_issue([d], client=client, chall_type="http-01") 157 except acme_errors.ValidationError as e: 158 for authzr in e.failed_authzrs: 159 c = chisel2.get_chall(authzr, challenges.HTTP01) 160 error = c.error 161 if error is None or error.typ != "urn:ietf:params:acme:error:connection": 162 raise(Exception("Expected connection prob, got %s" % (error.__str__()))) 163 if error.detail != expectedError: 164 raise(Exception("Expected prob detail %s, got %s" % (expectedError, error.detail))) 165 166 challSrv.remove_http_redirect(challengePath) 167 168 def test_failed_validation_limit(): 169 """ 170 Fail a challenge repeatedly for the same domain, with the same account. Once 171 we reach the rate limit we should get a rateLimitedError. Note that this 172 depends on the specific threshold configured. 173 174 This also incidentally tests a fix for 175 https://github.com/letsencrypt/boulder/issues/4329. We expect to get 176 ValidationErrors, eventually followed by a rate limit error. 177 """ 178 domain = "fail." + random_domain() 179 csr_pem = chisel2.make_csr([domain]) 180 client = chisel2.make_client() 181 threshold = 3 182 for _ in range(threshold): 183 order = client.new_order(csr_pem) 184 chall = chisel2.get_any_supported_chall(order.authorizations[0]) 185 client.answer_challenge(chall, chall.response(client.net.key)) 186 try: 187 client.poll_and_finalize(order) 188 except errors.ValidationError as e: 189 pass 190 chisel2.expect_problem("urn:ietf:params:acme:error:rateLimited", 191 lambda: chisel2.auth_and_issue([domain], client=client)) 192 193 194 def test_http_challenge_loop_redirect(): 195 client = chisel2.make_client() 196 197 # Create an authz for a random domain and get its HTTP-01 challenge token 198 d, chall = rand_http_chall(client) 199 token = chall.encode("token") 200 201 # Create a HTTP redirect from the challenge's validation path to itself 202 challengePath = "/.well-known/acme-challenge/{0}".format(token) 203 challSrv.add_http_redirect( 204 challengePath, 205 "http://{0}{1}".format(d, challengePath)) 206 207 # Issuing for the name should fail because of the challenge domains's 208 # redirect loop. 209 chisel2.expect_problem("urn:ietf:params:acme:error:connection", 210 lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) 211 212 challSrv.remove_http_redirect(challengePath) 213 214 def test_http_challenge_badport_redirect(): 215 client = chisel2.make_client() 216 217 # Create an authz for a random domain and get its HTTP-01 challenge token 218 d, chall = rand_http_chall(client) 219 token = chall.encode("token") 220 221 # Create a HTTP redirect from the challenge's validation path to a host with 222 # an invalid port. 223 challengePath = "/.well-known/acme-challenge/{0}".format(token) 224 challSrv.add_http_redirect( 225 challengePath, 226 "http://{0}:1337{1}".format(d, challengePath)) 227 228 # Issuing for the name should fail because of the challenge domain's 229 # invalid port redirect. 230 chisel2.expect_problem("urn:ietf:params:acme:error:connection", 231 lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) 232 233 challSrv.remove_http_redirect(challengePath) 234 235 def test_http_challenge_badhost_redirect(): 236 client = chisel2.make_client() 237 238 # Create an authz for a random domain and get its HTTP-01 challenge token 239 d, chall = rand_http_chall(client) 240 token = chall.encode("token") 241 242 # Create a HTTP redirect from the challenge's validation path to a bare IP 243 # hostname. 244 challengePath = "/.well-known/acme-challenge/{0}".format(token) 245 challSrv.add_http_redirect( 246 challengePath, 247 "https://127.0.0.1{0}".format(challengePath)) 248 249 # Issuing for the name should cause a connection error because the redirect 250 # domain name is an IP address. 251 chisel2.expect_problem("urn:ietf:params:acme:error:connection", 252 lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) 253 254 challSrv.remove_http_redirect(challengePath) 255 256 def test_http_challenge_badproto_redirect(): 257 client = chisel2.make_client() 258 259 # Create an authz for a random domain and get its HTTP-01 challenge token 260 d, chall = rand_http_chall(client) 261 token = chall.encode("token") 262 263 # Create a HTTP redirect from the challenge's validation path to whacky 264 # non-http/https protocol URL. 265 challengePath = "/.well-known/acme-challenge/{0}".format(token) 266 challSrv.add_http_redirect( 267 challengePath, 268 "gopher://{0}{1}".format(d, challengePath)) 269 270 # Issuing for the name should cause a connection error because the redirect 271 # domain name is an IP address. 272 chisel2.expect_problem("urn:ietf:params:acme:error:connection", 273 lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) 274 275 challSrv.remove_http_redirect(challengePath) 276 277 def test_http_challenge_http_redirect(): 278 client = chisel2.make_client() 279 280 # Create an authz for a random domain and get its HTTP-01 challenge token 281 d, chall = rand_http_chall(client) 282 token = chall.encode("token") 283 # Calculate its keyauth so we can add it in a special non-standard location 284 # for the redirect result 285 resp = chall.response(client.net.key) 286 keyauth = resp.key_authorization 287 challSrv.add_http01_response("http-redirect", keyauth) 288 289 # Create a HTTP redirect from the challenge's validation path to some other 290 # token path where we have registered the key authorization. 291 challengePath = "/.well-known/acme-challenge/{0}".format(token) 292 redirectPath = "/.well-known/acme-challenge/http-redirect?params=are&important=to¬=lose" 293 challSrv.add_http_redirect( 294 challengePath, 295 "http://{0}{1}".format(d, redirectPath)) 296 297 chisel2.auth_and_issue([d], client=client, chall_type="http-01") 298 299 challSrv.remove_http_redirect(challengePath) 300 challSrv.remove_http01_response("http-redirect") 301 302 history = challSrv.http_request_history(d) 303 challSrv.clear_http_request_history(d) 304 305 # There should have been at least two GET requests made to the 306 # challtestsrv. There may have been more if remote VAs were configured. 307 if len(history) < 2: 308 raise(Exception("Expected at least 2 HTTP request events on challtestsrv, found {1}".format(len(history)))) 309 310 initialRequests = [] 311 redirectedRequests = [] 312 313 for request in history: 314 # All requests should have been over HTTP 315 if request['HTTPS'] is True: 316 raise(Exception("Expected all requests to be HTTP")) 317 # Initial requests should have the expected initial HTTP-01 URL for the challenge 318 if request['URL'] == challengePath: 319 initialRequests.append(request) 320 # Redirected requests should have the expected redirect path URL with all 321 # its parameters 322 elif request['URL'] == redirectPath: 323 redirectedRequests.append(request) 324 else: 325 raise(Exception("Unexpected request URL {0} in challtestsrv history: {1}".format(request['URL'], request))) 326 327 # There should have been at least 1 initial HTTP-01 validation request. 328 if len(initialRequests) < 1: 329 raise(Exception("Expected {0} initial HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(initialRequests)))) 330 331 # There should have been at least 1 redirected HTTP request for each VA 332 if len(redirectedRequests) < 1: 333 raise(Exception("Expected {0} redirected HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(redirectedRequests)))) 334 335 def test_http_challenge_https_redirect(): 336 client = chisel2.make_client() 337 338 # Create an authz for a random domain and get its HTTP-01 challenge token 339 d, chall = rand_http_chall(client) 340 token = chall.encode("token") 341 # Calculate its keyauth so we can add it in a special non-standard location 342 # for the redirect result 343 resp = chall.response(client.net.key) 344 keyauth = resp.key_authorization 345 challSrv.add_http01_response("https-redirect", keyauth) 346 347 # Create a HTTP redirect from the challenge's validation path to an HTTPS 348 # path with some parameters 349 challengePath = "/.well-known/acme-challenge/{0}".format(token) 350 redirectPath = "/.well-known/acme-challenge/https-redirect?params=are&important=to¬=lose" 351 challSrv.add_http_redirect( 352 challengePath, 353 "https://{0}{1}".format(d, redirectPath)) 354 355 # Also add an A record for the domain pointing to the interface that the 356 # HTTPS HTTP-01 challtestsrv is bound. 357 challSrv.add_a_record(d, ["64.112.117.122"]) 358 359 try: 360 chisel2.auth_and_issue([d], client=client, chall_type="http-01") 361 except errors.ValidationError as e: 362 problems = [] 363 for authzr in e.failed_authzrs: 364 for chall in authzr.body.challenges: 365 error = chall.error 366 if error: 367 problems.append(error.__str__()) 368 raise(Exception("validation problem: %s" % "; ".join(problems))) 369 370 challSrv.remove_http_redirect(challengePath) 371 challSrv.remove_a_record(d) 372 373 history = challSrv.http_request_history(d) 374 challSrv.clear_http_request_history(d) 375 376 # There should have been at least two GET requests made to the challtestsrv by the VA 377 if len(history) < 2: 378 raise(Exception("Expected 2 HTTP request events on challtestsrv, found {0}".format(len(history)))) 379 380 initialRequests = [] 381 redirectedRequests = [] 382 383 for request in history: 384 # Initial requests should have the expected initial HTTP-01 URL for the challenge 385 if request['URL'] == challengePath: 386 initialRequests.append(request) 387 # Redirected requests should have the expected redirect path URL with all 388 # its parameters 389 elif request['URL'] == redirectPath: 390 redirectedRequests.append(request) 391 else: 392 raise(Exception("Unexpected request URL {0} in challtestsrv history: {1}".format(request['URL'], request))) 393 394 # There should have been at least 1 initial HTTP-01 validation request. 395 if len(initialRequests) < 1: 396 raise(Exception("Expected {0} initial HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(initialRequests)))) 397 # All initial requests should have been over HTTP 398 for r in initialRequests: 399 if r['HTTPS'] is True: 400 raise(Exception("Expected all initial requests to be HTTP, got %s" % r)) 401 402 # There should have been at least 1 redirected HTTP request for each VA 403 if len(redirectedRequests) < 1: 404 raise(Exception("Expected {0} redirected HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(redirectedRequests)))) 405 # All the redirected requests should have been over HTTPS with the correct 406 # SNI value 407 for r in redirectedRequests: 408 if r['HTTPS'] is False: 409 raise(Exception("Expected all redirected requests to be HTTPS")) 410 if r['ServerName'] != d: 411 raise(Exception("Expected all redirected requests to have ServerName {0} got \"{1}\"".format(d, r['ServerName']))) 412 413 class SlowHTTPRequestHandler(BaseHTTPRequestHandler): 414 def do_GET(self): 415 try: 416 # Sleeptime needs to be larger than the RA->VA timeout (20s at the 417 # time of writing) 418 sleeptime = 22 419 print("SlowHTTPRequestHandler: sleeping for {0}s\n".format(sleeptime)) 420 time.sleep(sleeptime) 421 self.send_response(200) 422 self.end_headers() 423 self.wfile.write(b"this is not an ACME key authorization") 424 except: 425 pass 426 427 class SlowHTTPServer(HTTPServer): 428 # Override handle_error so we don't print a misleading stack trace when the 429 # VA terminates the connection due to timeout. 430 def handle_error(self, request, client_address): 431 pass 432 433 def test_http_challenge_timeout(): 434 """ 435 test_http_challenge_timeout tests that the VA times out challenge requests 436 to a slow HTTP server appropriately. 437 """ 438 # Start a simple python HTTP server on port 80 in its own thread. 439 # NOTE(@cpu): The chall-test-srv binds 64.112.117.122:80 for HTTP-01 440 # challenges so we must use the 64.112.117.134 address for the throw away 441 # server for this test and add a mock DNS entry that directs the VA to it. 442 httpd = SlowHTTPServer(("64.112.117.134", 80), SlowHTTPRequestHandler) 443 thread = threading.Thread(target = httpd.serve_forever) 444 thread.daemon = False 445 thread.start() 446 447 # Pick a random domain 448 hostname = random_domain() 449 450 # Add A record for the domains to ensure the VA's requests are directed 451 # to the interface that we bound the HTTPServer to. 452 challSrv.add_a_record(hostname, ["64.112.117.134"]) 453 454 start = datetime.datetime.utcnow() 455 end = 0 456 457 try: 458 # We expect a connection timeout error to occur 459 chisel2.expect_problem("urn:ietf:params:acme:error:connection", 460 lambda: chisel2.auth_and_issue([hostname], chall_type="http-01")) 461 end = datetime.datetime.utcnow() 462 finally: 463 # Shut down the HTTP server gracefully and join on its thread. 464 httpd.shutdown() 465 httpd.server_close() 466 thread.join() 467 468 delta = end - start 469 # Expected duration should be the RA->VA timeout plus some padding (At 470 # present the timeout is 20s so adding 2s of padding = 22s) 471 expectedDuration = 22 472 if delta.total_seconds() == 0 or delta.total_seconds() > expectedDuration: 473 raise(Exception("expected timeout to occur in under {0} seconds. Took {1}".format(expectedDuration, delta.total_seconds()))) 474 475 476 def test_overlapping_wildcard(): 477 """ 478 Test issuance for a random domain and a wildcard version of the same domain 479 using DNS-01. This should result in *two* distinct authorizations. 480 """ 481 domain = random_domain() 482 domains = [ domain, "*."+domain ] 483 client = chisel2.make_client(None) 484 csr_pem = chisel2.make_csr(domains) 485 order = client.new_order(csr_pem) 486 authzs = order.authorizations 487 488 if len(authzs) != 2: 489 raise(Exception("order for %s had %d authorizations, expected 2" % 490 (domains, len(authzs)))) 491 492 cleanup = chisel2.do_dns_challenges(client, authzs) 493 try: 494 order = client.poll_and_finalize(order) 495 finally: 496 cleanup() 497 498 def test_highrisk_blocklist(): 499 """ 500 Test issuance for a subdomain of a HighRiskBlockedNames entry. It should 501 fail with a policy error. 502 """ 503 504 # We include "example.org" in `test/ident-policy.yaml` in the 505 # HighRiskBlockedNames list so issuing for "foo.example.org" should be 506 # blocked. 507 domain = "foo.example.org" 508 # We expect this to produce a policy problem 509 chisel2.expect_problem("urn:ietf:params:acme:error:rejectedIdentifier", 510 lambda: chisel2.auth_and_issue([domain], chall_type="dns-01")) 511 512 def test_wildcard_exactblacklist(): 513 """ 514 Test issuance for a wildcard that would cover an exact blacklist entry. It 515 should fail with a policy error. 516 """ 517 518 # We include "highrisk.le-test.hoffman-andrews.com" in `test/ident-policy.yaml` 519 # Issuing for "*.le-test.hoffman-andrews.com" should be blocked 520 domain = "*.le-test.hoffman-andrews.com" 521 # We expect this to produce a policy problem 522 chisel2.expect_problem("urn:ietf:params:acme:error:rejectedIdentifier", 523 lambda: chisel2.auth_and_issue([domain], chall_type="dns-01")) 524 525 def test_wildcard_authz_reuse(): 526 """ 527 Test that an authorization for a base domain obtained via HTTP-01 isn't 528 reused when issuing a wildcard for that base domain later on. 529 """ 530 531 # Create one client to reuse across multiple issuances 532 client = chisel2.make_client(None) 533 534 # Pick a random domain to issue for 535 domains = [ random_domain() ] 536 csr_pem = chisel2.make_csr(domains) 537 538 # Submit an order for the name 539 order = client.new_order(csr_pem) 540 # Complete the order via an HTTP-01 challenge 541 cleanup = chisel2.do_http_challenges(client, order.authorizations) 542 try: 543 order = client.poll_and_finalize(order) 544 finally: 545 cleanup() 546 547 # Now try to issue a wildcard for the random domain 548 domains[0] = "*." + domains[0] 549 csr_pem = chisel2.make_csr(domains) 550 order = client.new_order(csr_pem) 551 552 # We expect all of the returned authorizations to be pending status 553 for authz in order.authorizations: 554 if authz.body.status != Status("pending"): 555 raise(Exception("order for %s included a non-pending authorization (status: %s) from a previous HTTP-01 order" % 556 ((domains), str(authz.body.status)))) 557 558 def test_bad_overlap_wildcard(): 559 chisel2.expect_problem("urn:ietf:params:acme:error:malformed", 560 lambda: chisel2.auth_and_issue(["*.example.com", "www.example.com"])) 561 562 def test_duplicate_orders(): 563 """ 564 Test that the same client issuing for the same domain names twice in a row 565 works without error. 566 """ 567 client = chisel2.make_client(None) 568 domains = [ random_domain() ] 569 chisel2.auth_and_issue(domains, client=client) 570 chisel2.auth_and_issue(domains, client=client) 571 572 def test_order_reuse_failed_authz(): 573 """ 574 Test that creating an order for a domain name, failing an authorization in 575 that order, and submitting another new order request for the same name 576 doesn't reuse a failed authorization in the new order. 577 """ 578 579 client = chisel2.make_client(None) 580 domains = [ random_domain() ] 581 csr_pem = chisel2.make_csr(domains) 582 583 order = client.new_order(csr_pem) 584 firstOrderURI = order.uri 585 586 # Pick the first authz's first supported challenge, doesn't matter what 587 # type it is 588 chall_body = chisel2.get_any_supported_chall(order.authorizations[0]) 589 # Answer it, but with nothing set up to solve the challenge request 590 client.answer_challenge(chall_body, chall_body.response(client.net.key)) 591 592 deadline = datetime.datetime.now() + datetime.timedelta(seconds=60) 593 authzFailed = False 594 try: 595 # Poll the order's authorizations until they are non-pending, a timeout 596 # occurs, or there is an invalid authorization status. 597 client.poll_authorizations(order, deadline) 598 except acme_errors.ValidationError as e: 599 # We expect there to be a ValidationError from one of the authorizations 600 # being invalid. 601 authzFailed = True 602 603 # If the poll ended and an authz's status isn't invalid then we reached the 604 # deadline, fail the test 605 if not authzFailed: 606 raise(Exception("timed out waiting for order %s to become invalid" % firstOrderURI)) 607 608 # Make another order with the same domains 609 order = client.new_order(csr_pem) 610 611 # It should not be the same order as before 612 if order.uri == firstOrderURI: 613 raise(Exception("new-order for %s returned a , now-invalid, order" % domains)) 614 615 # We expect all of the returned authorizations to be pending status 616 for authz in order.authorizations: 617 if authz.body.status != Status("pending"): 618 raise(Exception("order for %s included a non-pending authorization (status: %s) from a previous order" % 619 ((domains), str(authz.body.status)))) 620 621 # We expect the new order can be fulfilled 622 cleanup = chisel2.do_http_challenges(client, order.authorizations) 623 try: 624 order = client.poll_and_finalize(order) 625 finally: 626 cleanup() 627 628 def test_only_return_existing_reg(): 629 client = chisel2.uninitialized_client() 630 email = "test@not-example.com" 631 client.new_account(messages.NewRegistration.from_data(email=email, 632 terms_of_service_agreed=True)) 633 634 client = chisel2.uninitialized_client(key=client.net.key) 635 class extendedAcct(dict): 636 def json_dumps(self, indent=None): 637 return json.dumps(self) 638 acct = extendedAcct({ 639 "termsOfServiceAgreed": True, 640 "contact": [email], 641 "onlyReturnExisting": True 642 }) 643 resp = client.net.post(client.directory['newAccount'], acct) 644 if resp.status_code != 200: 645 raise(Exception("incorrect response returned for onlyReturnExisting")) 646 647 other_client = chisel2.uninitialized_client() 648 newAcct = extendedAcct({ 649 "termsOfServiceAgreed": True, 650 "contact": [email], 651 "onlyReturnExisting": True 652 }) 653 chisel2.expect_problem("urn:ietf:params:acme:error:accountDoesNotExist", 654 lambda: other_client.net.post(other_client.directory['newAccount'], newAcct)) 655 656 def BouncerHTTPRequestHandler(redirect, guestlist): 657 """ 658 BouncerHTTPRequestHandler returns a BouncerHandler class that acts like 659 a club bouncer in front of another server. The bouncer will respond to 660 GET requests by looking up the allowed number of requests in the guestlist 661 for the User-Agent making the request. If there is at least one guestlist 662 spot for that UA it will be redirected to the real server and the 663 guestlist will be decremented. Once the guestlist spots for a UA are 664 expended requests will get a bogus result and have to stand outside in the 665 cold 666 """ 667 class BouncerHandler(BaseHTTPRequestHandler): 668 def __init__(self, *args, **kwargs): 669 BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 670 671 def do_HEAD(self): 672 # This is used by wait_for_server 673 self.send_response(200) 674 self.end_headers() 675 676 def do_GET(self): 677 ua = self.headers['User-Agent'] 678 guestlistAllows = BouncerHandler.guestlist.get(ua, 0) 679 # If there is still space on the guestlist for this UA then redirect 680 # the request and decrement the guestlist. 681 if guestlistAllows > 0: 682 BouncerHandler.guestlist[ua] -= 1 683 self.log_message("BouncerHandler UA {0} is on the Guestlist. {1} requests remaining.".format(ua, BouncerHandler.guestlist[ua])) 684 self.send_response(302) 685 self.send_header("Location", BouncerHandler.redirect) 686 self.end_headers() 687 # Otherwise return a bogus result 688 else: 689 self.log_message("BouncerHandler UA {0} has no requests on the Guestlist. Sending request to the curb".format(ua)) 690 self.send_response(200) 691 self.end_headers() 692 self.wfile.write(u"(• ◡ •) <( VIPs only! )".encode()) 693 694 BouncerHandler.guestlist = guestlist 695 BouncerHandler.redirect = redirect 696 return BouncerHandler 697 698 def wait_for_server(addr): 699 while True: 700 try: 701 # NOTE(@cpu): Using HEAD here instead of GET because the 702 # BouncerHandler modifies its state for GET requests. 703 status = requests.head(addr).status_code 704 if status == 200: 705 return 706 except requests.exceptions.ConnectionError: 707 pass 708 time.sleep(0.5) 709 710 def multiva_setup(client, guestlist): 711 """ 712 Setup a testing domain and backing multiva server setup. This will block 713 until the server is ready. The returned cleanup function should be used to 714 stop the server. The first bounceFirst requests to the server will be sent 715 to the real challtestsrv for a good answer, the rest will get a bad 716 answer. Domain name is randomly chosen with random_domain(). 717 """ 718 hostname = random_domain() 719 720 csr_pem = chisel2.make_csr([hostname]) 721 order = client.new_order(csr_pem) 722 authz = order.authorizations[0] 723 chall = None 724 for c in authz.body.challenges: 725 if isinstance(c.chall, challenges.HTTP01): 726 chall = c.chall 727 if chall is None: 728 raise(Exception("No HTTP-01 challenge found for random domain authz")) 729 730 token = chall.encode("token") 731 732 # Calculate the challenge's keyauth so we can add a good keyauth response on 733 # the real challtestsrv that we redirect VIP requests to. 734 resp = chall.response(client.net.key) 735 keyauth = resp.key_authorization 736 challSrv.add_http01_response(token, keyauth) 737 738 # Add an A record for the domains to ensure the VA's requests are directed 739 # to the interface that we bound the HTTPServer to. 740 challSrv.add_a_record(hostname, ["64.112.117.134"]) 741 742 # Add an A record for the redirect target that sends it to the real chall 743 # test srv for a valid HTTP-01 response. 744 redirHostname = "chall-test-srv.example.com" 745 challSrv.add_a_record(redirHostname, ["64.112.117.122"]) 746 747 # Start a simple python HTTP server on port 80 in its own thread. 748 # NOTE(@cpu): The chall-test-srv binds 64.112.117.122:80 for HTTP-01 749 # challenges so we must use the 64.112.117.134 address for the throw away 750 # server for this test and add a mock DNS entry that directs the VA to it. 751 redirect = "http://{0}/.well-known/acme-challenge/{1}".format( 752 redirHostname, token) 753 httpd = HTTPServer(("64.112.117.134", 80), BouncerHTTPRequestHandler(redirect, guestlist)) 754 thread = threading.Thread(target = httpd.serve_forever) 755 thread.daemon = False 756 thread.start() 757 758 def cleanup(): 759 # Remove the challtestsrv mocks 760 challSrv.remove_a_record(hostname) 761 challSrv.remove_a_record(redirHostname) 762 challSrv.remove_http01_response(token) 763 # Shut down the HTTP server gracefully and join on its thread. 764 httpd.shutdown() 765 httpd.server_close() 766 thread.join() 767 768 return hostname, cleanup 769 770 def test_http_multiva_threshold_pass(): 771 client = chisel2.make_client() 772 773 # Configure a guestlist that will pass the multiVA threshold test by 774 # allowing the primary VA at some, but not all, remotes. 775 # In particular, remoteva-c is missing. 776 guestlist = {"boulder": 1, "remoteva-a": 1, "remoteva-b": 1} 777 778 hostname, cleanup = multiva_setup(client, guestlist) 779 780 try: 781 # With the maximum number of allowed remote VA failures the overall 782 # challenge should still succeed. 783 chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") 784 finally: 785 cleanup() 786 787 def test_http_multiva_primary_fail_remote_pass(): 788 client = chisel2.make_client() 789 790 # Configure a guestlist that will fail the primary VA check but allow all of 791 # the remote VAs. 792 guestlist = {"boulder": 0, "remoteva-a": 1, "remoteva-b": 1} 793 794 hostname, cleanup = multiva_setup(client, guestlist) 795 796 foundException = False 797 798 try: 799 # The overall validation should fail even if the remotes are allowed 800 # because the primary VA result cannot be overridden. 801 chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") 802 except acme_errors.ValidationError as e: 803 # NOTE(@cpu): Chisel2's expect_problem doesn't work in this case so this 804 # test needs to unpack an `acme_errors.ValidationError` on its own. It 805 # might be possible to clean this up in the future. 806 if len(e.failed_authzrs) != 1: 807 raise(Exception("expected one failed authz, found {0}".format(len(e.failed_authzrs)))) 808 challs = e.failed_authzrs[0].body.challenges 809 httpChall = None 810 for chall_body in challs: 811 if isinstance(chall_body.chall, challenges.HTTP01): 812 httpChall = chall_body 813 if httpChall is None: 814 raise(Exception("no HTTP-01 challenge in failed authz")) 815 if httpChall.error.typ != "urn:ietf:params:acme:error:unauthorized": 816 raise(Exception("expected unauthorized prob, found {0}".format(httpChall.error.typ))) 817 foundException = True 818 finally: 819 cleanup() 820 if foundException is False: 821 raise(Exception("Overall validation did not fail")) 822 823 def test_http_multiva_threshold_fail(): 824 client = chisel2.make_client() 825 826 # Configure a guestlist that will fail the multiVA threshold test by 827 # only allowing the primary VA. 828 guestlist = {"boulder": 1} 829 830 hostname, cleanup = multiva_setup(client, guestlist) 831 832 failed_authzrs = [] 833 try: 834 chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") 835 except acme_errors.ValidationError as e: 836 # NOTE(@cpu): Chisel2's expect_problem doesn't work in this case so this 837 # test needs to unpack an `acme_errors.ValidationError` on its own. It 838 # might be possible to clean this up in the future. 839 failed_authzrs = e.failed_authzrs 840 finally: 841 cleanup() 842 if len(failed_authzrs) != 1: 843 raise(Exception("expected one failed authz, found {0}".format(len(failed_authzrs)))) 844 challs = failed_authzrs[0].body.challenges 845 httpChall = None 846 for chall_body in challs: 847 if isinstance(chall_body.chall, challenges.HTTP01): 848 httpChall = chall_body 849 if httpChall is None: 850 raise(Exception("no HTTP-01 challenge in failed authz")) 851 if httpChall.error.typ != "urn:ietf:params:acme:error:unauthorized": 852 raise(Exception("expected unauthorized prob, found {0}".format(httpChall.error.typ))) 853 if not httpChall.error.detail.startswith("During secondary validation: "): 854 raise(Exception("expected 'During secondary validation' problem detail, found {0}".format(httpChall.error.detail))) 855 856 class FakeH2ServerHandler(socketserver.BaseRequestHandler): 857 """ 858 FakeH2ServerHandler is a TCP socket handler that writes data representing an 859 initial HTTP/2 SETTINGS frame as a response to all received data. 860 """ 861 def handle(self): 862 # Read whatever the HTTP request was so that the response isn't seen as 863 # unsolicited. 864 self.data = self.request.recv(1024).strip() 865 # Blast some HTTP/2 bytes onto the socket 866 # Truncated example data from taken from the community forum: 867 # https://community.letsencrypt.org/t/le-validation-error-if-server-is-in-google-infrastructure/51841 868 self.request.sendall(b"\x00\x00\x12\x04\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x80\x00") 869 870 def wait_for_tcp_server(addr, port): 871 """ 872 wait_for_tcp_server attempts to make a TCP connection to the given 873 address/port every 0.5s until it succeeds. 874 """ 875 while True: 876 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 877 try: 878 sock.connect((addr, port)) 879 sock.sendall(b"\n") 880 return 881 except socket.error: 882 time.sleep(0.5) 883 pass 884 885 def test_http2_http01_challenge(): 886 """ 887 test_http2_http01_challenge tests that an HTTP-01 challenge made to a HTTP/2 888 server fails with a specific error message for this case. 889 """ 890 client = chisel2.make_client() 891 hostname = "fake.h2.example.com" 892 893 # Add an A record for the test server to ensure the VA's requests are directed 894 # to the interface that we bind the FakeH2ServerHandler to. 895 challSrv.add_a_record(hostname, ["64.112.117.134"]) 896 897 # Allow socket address reuse on the base TCPServer class. Failing to do this 898 # causes subsequent integration tests to fail with "Address in use" errors even 899 # though this test _does_ call shutdown() and server_close(). Even though the 900 # server was shut-down Python's socket will be in TIME_WAIT because of prev. client 901 # connections. Having the TCPServer set SO_REUSEADDR on the socket solves 902 # the problem. 903 socketserver.TCPServer.allow_reuse_address = True 904 # Create, start, and wait for a fake HTTP/2 server. 905 server = socketserver.TCPServer(("64.112.117.134", 80), FakeH2ServerHandler) 906 thread = threading.Thread(target = server.serve_forever) 907 thread.daemon = False 908 thread.start() 909 wait_for_tcp_server("64.112.117.134", 80) 910 911 # Issuing an HTTP-01 challenge for this hostname should produce a connection 912 # problem with an error specific to the HTTP/2 misconfiguration. 913 expectedError = "Server is speaking HTTP/2 over HTTP" 914 try: 915 chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") 916 except acme_errors.ValidationError as e: 917 for authzr in e.failed_authzrs: 918 c = chisel2.get_chall(authzr, challenges.HTTP01) 919 error = c.error 920 if error is None or error.typ != "urn:ietf:params:acme:error:connection": 921 raise(Exception("Expected connection prob, got %s" % (error.__str__()))) 922 if not error.detail.endswith(expectedError): 923 raise(Exception("Expected prob detail ending in %s, got %s" % (expectedError, error.detail))) 924 finally: 925 server.shutdown() 926 server.server_close() 927 thread.join() 928 929 def test_new_order_policy_errs(): 930 """ 931 Test that creating an order with policy blocked identifiers returns 932 a problem with subproblems. 933 """ 934 client = chisel2.make_client(None) 935 936 # 'in-addr.arpa' is present in `test/ident-policy.yaml`'s 937 # HighRiskBlockedNames list. 938 csr_pem = chisel2.make_csr(["out-addr.in-addr.arpa", "between-addr.in-addr.arpa"]) 939 940 # With two policy blocked names in the order we expect to get back a top 941 # level rejectedIdentifier with a detail message that references 942 # subproblems. 943 # 944 # TODO(@cpu): After https://github.com/certbot/certbot/issues/7046 is 945 # implemented in the upstream `acme` module this test should also ensure the 946 # subproblems are properly represented. 947 ok = False 948 try: 949 order = client.new_order(csr_pem) 950 except messages.Error as e: 951 ok = True 952 if e.typ != "urn:ietf:params:acme:error:rejectedIdentifier": 953 raise(Exception("Expected rejectedIdentifier type problem, got {0}".format(e.typ))) 954 if e.detail != 'Error creating new order :: Cannot issue for "between-addr.in-addr.arpa": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy (and 1 more problems. Refer to sub-problems for more information.)': 955 raise(Exception("Order problem detail did not match expected")) 956 if not ok: 957 raise(Exception("Expected problem, got no error")) 958 959 def test_delete_unused_challenges(): 960 order = chisel2.auth_and_issue([random_domain()], chall_type="dns-01") 961 a = order.authorizations[0] 962 if len(a.body.challenges) != 1: 963 raise(Exception("too many challenges (%d) left after validation" % len(a.body.challenges))) 964 if not isinstance(a.body.challenges[0].chall, challenges.DNS01): 965 raise(Exception("wrong challenge type left after validation")) 966 967 # intentionally fail a challenge 968 client = chisel2.make_client() 969 csr_pem = chisel2.make_csr([random_domain()]) 970 order = client.new_order(csr_pem) 971 c = chisel2.get_chall(order.authorizations[0], challenges.DNS01) 972 client.answer_challenge(c, c.response(client.net.key)) 973 for _ in range(5): 974 a, _ = client.poll(order.authorizations[0]) 975 if a.body.status == Status("invalid"): 976 break 977 time.sleep(1) 978 if len(a.body.challenges) != 1: 979 raise(Exception("too many challenges (%d) left after failed validation" % 980 len(a.body.challenges))) 981 if not isinstance(a.body.challenges[0].chall, challenges.DNS01): 982 raise(Exception("wrong challenge type left after validation")) 983 984 def test_auth_deactivation_v2(): 985 client = chisel2.make_client(None) 986 csr_pem = chisel2.make_csr([random_domain()]) 987 order = client.new_order(csr_pem) 988 resp = client.deactivate_authorization(order.authorizations[0]) 989 if resp.body.status is not messages.STATUS_DEACTIVATED: 990 raise(Exception("unexpected authorization status")) 991 992 order = chisel2.auth_and_issue([random_domain()], client=client) 993 resp = client.deactivate_authorization(order.authorizations[0]) 994 if resp.body.status is not messages.STATUS_DEACTIVATED: 995 raise(Exception("unexpected authorization status")) 996 997 def test_ct_submission(): 998 hostname = random_domain() 999 1000 chisel2.auth_and_issue([hostname]) 1001 1002 # These should correspond to the configured logs in ra.json. 1003 log_groups = [ 1004 ["http://boulder.service.consul:4600/submissions", "http://boulder.service.consul:4601/submissions", "http://boulder.service.consul:4602/submissions", "http://boulder.service.consul:4603/submissions"], 1005 ["http://boulder.service.consul:4604/submissions", "http://boulder.service.consul:4605/submissions"], 1006 ["http://boulder.service.consul:4606/submissions"], 1007 ["http://boulder.service.consul:4607/submissions"], 1008 ["http://boulder.service.consul:4608/submissions"], 1009 ["http://boulder.service.consul:4609/submissions"], 1010 ] 1011 1012 # These should correspond to the logs with `submitFinal` in ra.json. 1013 final_logs = [ 1014 "http://boulder.service.consul:4600/submissions", 1015 "http://boulder.service.consul:4601/submissions", 1016 "http://boulder.service.consul:4606/submissions", 1017 "http://boulder.service.consul:4609/submissions", 1018 ] 1019 1020 # We'd like to enforce strict limits here (exactly 1 submission per group, 1021 # exactly two submissions overall) but the async nature of the race system 1022 # means we can't -- a slowish submission to one log in a group could trigger 1023 # a very fast submission to a different log in the same group, and then both 1024 # submissions could succeed at the same time. Although the Go code will only 1025 # use one of the SCTs, both logs will still have been submitted to, and it 1026 # will show up here. 1027 total_count = 0 1028 for i in range(len(log_groups)): 1029 group_count = 0 1030 for j in range(len(log_groups[i])): 1031 log = log_groups[i][j] 1032 count = int(requests.get(log + "?hostnames=%s" % hostname).text) 1033 threshold = 1 1034 if log in final_logs: 1035 threshold += 1 1036 if count > threshold: 1037 raise(Exception("Got %d submissions for log %s, expected at most %d" % (count, log, threshold))) 1038 group_count += count 1039 total_count += group_count 1040 if total_count < 2: 1041 raise(Exception("Got %d total submissions, expected at least 2" % total_count)) 1042 1043 def test_caa_good(): 1044 domain = random_domain() 1045 challSrv.add_caa_issue(domain, "happy-hacker-ca.invalid") 1046 chisel2.auth_and_issue([domain]) 1047 1048 def test_caa_reject(): 1049 domain = random_domain() 1050 challSrv.add_caa_issue(domain, "sad-hacker-ca.invalid") 1051 chisel2.expect_problem("urn:ietf:params:acme:error:caa", 1052 lambda: chisel2.auth_and_issue([domain])) 1053 1054 def test_renewal_exemption(): 1055 """ 1056 Under a single domain, issue two certificates for different subdomains of 1057 the same name, then renewals of each of them. Since the certificatesPerName 1058 rate limit in testing is 2 per 90 days, and the renewals should not be 1059 counted under the renewal exemption, each of these issuances should succeed. 1060 Then do one last issuance (for a third subdomain of the same name) that we 1061 expect to be rate limited, just to check that the rate limit is actually 2, 1062 and we are testing what we think we are testing. See 1063 https://letsencrypt.org/docs/rate-limits/ for more details. 1064 """ 1065 base_domain = random_domain() 1066 # First issuance 1067 chisel2.auth_and_issue(["www." + base_domain]) 1068 # First Renewal 1069 chisel2.auth_and_issue(["www." + base_domain]) 1070 # Issuance of a different cert 1071 chisel2.auth_and_issue(["blog." + base_domain]) 1072 # Renew that one 1073 chisel2.auth_and_issue(["blog." + base_domain]) 1074 # Final, failed issuance, for another different cert 1075 chisel2.expect_problem("urn:ietf:params:acme:error:rateLimited", 1076 lambda: chisel2.auth_and_issue(["mail." + base_domain])) 1077 1078 def test_oversized_csr(): 1079 # Number of names is chosen to be one greater than the configured RA/CA maxNames 1080 numNames = 101 1081 # Generate numNames subdomains of a random domain 1082 base_domain = random_domain() 1083 domains = [ "{0}.{1}".format(str(n),base_domain) for n in range(numNames) ] 1084 # We expect issuing for these domains to produce a malformed error because 1085 # there are too many names in the request. 1086 chisel2.expect_problem("urn:ietf:params:acme:error:malformed", 1087 lambda: chisel2.auth_and_issue(domains)) 1088 1089 def parse_cert(order): 1090 return x509.load_pem_x509_certificate(order.fullchain_pem.encode(), default_backend()) 1091 1092 def test_sct_embedding(): 1093 order = chisel2.auth_and_issue([random_domain()]) 1094 cert = parse_cert(order) 1095 1096 # make sure there is no poison extension 1097 try: 1098 cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.3")) 1099 raise(Exception("certificate contains CT poison extension")) 1100 except x509.ExtensionNotFound: 1101 # do nothing 1102 pass 1103 1104 # make sure there is a SCT list extension 1105 try: 1106 sctList = cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.2")) 1107 except x509.ExtensionNotFound: 1108 raise(Exception("certificate doesn't contain SCT list extension")) 1109 if len(sctList.value) != 2: 1110 raise(Exception("SCT list contains wrong number of SCTs")) 1111 for sct in sctList.value: 1112 if sct.version != x509.certificate_transparency.Version.v1: 1113 raise(Exception("SCT contains wrong version")) 1114 if sct.entry_type != x509.certificate_transparency.LogEntryType.PRE_CERTIFICATE: 1115 raise(Exception("SCT contains wrong entry type")) 1116 delta = sct.timestamp - datetime.datetime.now() 1117 if abs(delta) > datetime.timedelta(hours=1): 1118 raise(Exception("Delta between SCT timestamp and now was too great " 1119 "%s vs %s (%s)" % (sct.timestamp, datetime.datetime.now(), delta))) 1120 1121 def test_auth_deactivation(): 1122 client = chisel2.make_client(None) 1123 d = random_domain() 1124 csr_pem = chisel2.make_csr([d]) 1125 order = client.new_order(csr_pem) 1126 1127 resp = client.deactivate_authorization(order.authorizations[0]) 1128 if resp.body.status is not messages.STATUS_DEACTIVATED: 1129 raise Exception("unexpected authorization status") 1130 1131 order = chisel2.auth_and_issue([random_domain()], client=client) 1132 resp = client.deactivate_authorization(order.authorizations[0]) 1133 if resp.body.status is not messages.STATUS_DEACTIVATED: 1134 raise Exception("unexpected authorization status")