1010import os
1111import socket
1212import socketserver
13+ import ssl
1314import sys
1415import threading
1516import time
@@ -155,6 +156,10 @@ class TestHTTPServer(socketserver.ThreadingTCPServer):
155156class TestHTTPRequestHandler (http .server .SimpleHTTPRequestHandler ):
156157 static_directory : str
157158 wpt_directory : str
159+ aia_artifacts : dict
160+ aia_fetch_counts : dict
161+ aia_lock : Any
162+ aia_config : Optional [dict ]
158163
159164 def __init__ (self , * arguments , ** kwargs ):
160165 super ().__init__ (* arguments , directory = self .static_directory , ** kwargs )
@@ -252,6 +257,54 @@ def _serve_wpt_file_request(self):
252257 def _serve_static_request (self ):
253258 self ._serve_wpt_file_request ()
254259
260+ def _serve_aia_artifact (self , request_path ):
261+ # Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL.
262+ # Fetched by RequestServer, not by the page.
263+ lock = getattr (TestHTTPRequestHandler , "aia_lock" , None )
264+ if lock :
265+ with lock :
266+ counts = TestHTTPRequestHandler .aia_fetch_counts
267+ counts [request_path ] = counts .get (request_path , 0 ) + 1
268+ entry = getattr (TestHTTPRequestHandler , "aia_artifacts" , {}).get (request_path )
269+ if entry is None :
270+ # for example, /aia/dead: a deliberately missing artifact, to exercise the failure path.
271+ self .send_error (404 , "Not Found" )
272+ return
273+ content_type , body = entry
274+ self .send_response (200 )
275+ self .send_header ("Content-Type" , content_type )
276+ self .send_header ("Content-Length" , str (len (body )))
277+ self .end_headers ()
278+ self .wfile .write (body )
279+
280+ def _serve_aia_config (self ):
281+ # Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port).
282+ config = getattr (TestHTTPRequestHandler , "aia_config" , None )
283+ body = json .dumps (config or {}).encode ("utf-8" )
284+ self .send_response (200 if config else 503 )
285+ self .send_header ("Content-Type" , "application/json" )
286+ self .send_header ("Access-Control-Allow-Origin" , "*" )
287+ self .send_header ("Content-Length" , str (len (body )))
288+ self .end_headers ()
289+ self .wfile .write (body )
290+
291+ def _serve_aia_fetch_counts (self ):
292+ # Exposes how many times each AIA URL has been fetched — so the test can assert negative caching (a dead URL is
293+ # fetched once, not retried to the budget) and de-dupe (a shared intermediate is fetched once across two hosts).
294+ lock = getattr (TestHTTPRequestHandler , "aia_lock" , None )
295+ if lock :
296+ with lock :
297+ snapshot = dict (getattr (TestHTTPRequestHandler , "aia_fetch_counts" , {}))
298+ else :
299+ snapshot = {}
300+ body = json .dumps (snapshot ).encode ("utf-8" )
301+ self .send_response (200 )
302+ self .send_header ("Content-Type" , "application/json" )
303+ self .send_header ("Access-Control-Allow-Origin" , "*" )
304+ self .send_header ("Content-Length" , str (len (body )))
305+ self .end_headers ()
306+ self .wfile .write (body )
307+
255308 def do_GET (self ):
256309 if self .headers .get ("Upgrade" , "" ).lower () == "websocket" :
257310 self ._serve_websocket_echo ()
@@ -267,6 +320,12 @@ def do_GET(self):
267320 self ._serve_recorded_request_headers ()
268321 elif request_path .endswith (".py" ):
269322 self ._serve_wpt_python_script ()
323+ elif request_path .startswith ("/aia/" ):
324+ self ._serve_aia_artifact (request_path )
325+ elif request_path == "/aia-test/config" :
326+ self ._serve_aia_config ()
327+ elif request_path == "/aia-test/fetch-counts" :
328+ self ._serve_aia_fetch_counts ()
270329 else :
271330 self ._serve_static_request ()
272331
@@ -636,7 +695,213 @@ def _send_websocket_close(self):
636695 pass
637696
638697
639- def start_server (port , static_directory ):
698+ class AIALeafHandler (http .server .BaseHTTPRequestHandler ):
699+ def do_GET (self ):
700+ self .send_response (200 )
701+ self .send_header ("Content-Type" , "text/plain" )
702+ self .send_header ("Content-Length" , "2" )
703+ self .end_headers ()
704+ self .wfile .write (b"ok" )
705+
706+ def log_message (self , format : str , * args : Any ) -> None :
707+ pass
708+
709+
710+ class AIATLSServer (socketserver .ThreadingTCPServer ):
711+ daemon_threads = True
712+ allow_reuse_address = True
713+
714+ def __init__ (self , server_address , handler , ssl_context ):
715+ super ().__init__ (server_address , handler )
716+ self ._ssl_context = ssl_context
717+
718+ def get_request (self ):
719+ sock , addr = super ().get_request ()
720+ try :
721+ return self ._ssl_context .wrap_socket (sock , server_side = True ), addr
722+ except OSError :
723+ sock .close ()
724+ raise
725+
726+
727+ def _setup_aia_test_servers (http_port , ca_cert_output ):
728+ try :
729+ from cryptography import x509
730+ from cryptography .hazmat .primitives import hashes
731+ from cryptography .hazmat .primitives .asymmetric import ec
732+ from cryptography .hazmat .primitives .serialization import Encoding
733+ from cryptography .hazmat .primitives .serialization import NoEncryption
734+ from cryptography .hazmat .primitives .serialization import PrivateFormat
735+ from cryptography .hazmat .primitives .serialization import pkcs7
736+ from cryptography .x509 .oid import AuthorityInformationAccessOID
737+ from cryptography .x509 .oid import ExtendedKeyUsageOID
738+ from cryptography .x509 .oid import NameOID
739+ except ImportError as error :
740+ logging .warning ("AIA test setup skipped: python 'cryptography' unavailable (%s)" , error )
741+ return
742+
743+ import datetime
744+ import tempfile
745+
746+ def new_key ():
747+ return ec .generate_private_key (ec .SECP256R1 ())
748+
749+ def dn (common_name ):
750+ return x509 .Name ([x509 .NameAttribute (NameOID .COMMON_NAME , common_name )])
751+
752+ not_before , not_after = datetime .datetime (2020 , 1 , 1 ), datetime .datetime (2050 , 1 , 1 )
753+
754+ root_key = new_key ()
755+ root = (
756+ x509 .CertificateBuilder ()
757+ .subject_name (dn ("Ladybird AIA Test Root" ))
758+ .issuer_name (dn ("Ladybird AIA Test Root" ))
759+ .public_key (root_key .public_key ())
760+ .serial_number (x509 .random_serial_number ())
761+ .not_valid_before (not_before )
762+ .not_valid_after (not_after )
763+ .add_extension (x509 .BasicConstraints (ca = True , path_length = None ), critical = True )
764+ .sign (root_key , hashes .SHA256 ())
765+ )
766+
767+ aia_base = f"http://127.0.0.1:{ http_port } /aia"
768+ artifacts = {}
769+ scenarios = {}
770+
771+ def add_scenario (name , host , artifact_name , encoding , shared = None ):
772+ # By default, each scenario gets its own intermediate — so fetching one never populates the cache for another
773+ # (which would mask whether that scenario's own fetch and parse ran). The de-dupe scenarios instead share one
774+ # intermediate — to check it's fetched only once.
775+ if shared is not None :
776+ intermediate , intermediate_key = shared
777+ else :
778+ intermediate_key = new_key ()
779+ intermediate = (
780+ x509 .CertificateBuilder ()
781+ .subject_name (dn (f"Ladybird AIA Test Intermediate ({ name } )" ))
782+ .issuer_name (root .subject )
783+ .public_key (intermediate_key .public_key ())
784+ .serial_number (x509 .random_serial_number ())
785+ .not_valid_before (not_before )
786+ .not_valid_after (not_after )
787+ .add_extension (x509 .BasicConstraints (ca = True , path_length = 0 ), critical = True )
788+ .sign (root_key , hashes .SHA256 ())
789+ )
790+ if encoding == "der" :
791+ artifacts [f"/aia/{ artifact_name } " ] = ("application/pkix-cert" , intermediate .public_bytes (Encoding .DER ))
792+ aia_url = f"{ aia_base } /{ artifact_name } "
793+ elif encoding == "pkcs7" :
794+ artifacts [f"/aia/{ artifact_name } " ] = (
795+ "application/pkcs7-mime" ,
796+ pkcs7 .serialize_certificates ([intermediate ], Encoding .DER ),
797+ )
798+ aia_url = f"{ aia_base } /{ artifact_name } "
799+ else :
800+ aia_url = f"{ aia_base } /dead" # 404: the intermediate is never published
801+
802+ leaf_key = new_key ()
803+ leaf = (
804+ x509 .CertificateBuilder ()
805+ .subject_name (dn (host ))
806+ .issuer_name (intermediate .subject )
807+ .public_key (leaf_key .public_key ())
808+ .serial_number (x509 .random_serial_number ())
809+ .not_valid_before (not_before )
810+ .not_valid_after (not_after )
811+ .add_extension (x509 .SubjectAlternativeName ([x509 .DNSName ("localhost" )]), critical = False )
812+ .add_extension (x509 .ExtendedKeyUsage ([ExtendedKeyUsageOID .SERVER_AUTH ]), critical = False )
813+ .add_extension (
814+ x509 .AuthorityInformationAccess (
815+ [
816+ x509 .AccessDescription (
817+ AuthorityInformationAccessOID .CA_ISSUERS , x509 .UniformResourceIdentifier (aia_url )
818+ )
819+ ]
820+ ),
821+ critical = False ,
822+ )
823+ .sign (intermediate_key , hashes .SHA256 ())
824+ )
825+ scenarios [name ] = (leaf , leaf_key )
826+ return intermediate , intermediate_key
827+
828+ add_scenario ("good_der" , "good-der.localhost" , "intermediate-der.der" , "der" )
829+ add_scenario ("good_pkcs7" , "good-pkcs7.localhost" , "intermediate-pkcs7.p7c" , "pkcs7" )
830+ add_scenario ("dead" , "dead.localhost" , None , "dead" )
831+ shared_intermediate = add_scenario ("dedup_a" , "dedup-a.localhost" , "intermediate-dedup.der" , "der" )
832+ add_scenario ("dedup_b" , "dedup-b.localhost" , "intermediate-dedup.der" , "der" , shared_intermediate )
833+
834+ # Negative trust control: The caIssuers URL serves a self-signed cert that's *not* anchored in the trusted test
835+ # root. A fetched intermediate must never complete a chain to an untrusted anchor — so this host must fail to load.
836+ untrusted_key = new_key ()
837+ untrusted = (
838+ x509 .CertificateBuilder ()
839+ .subject_name (dn ("Ladybird AIA Test UNTRUSTED root" ))
840+ .issuer_name (dn ("Ladybird AIA Test UNTRUSTED root" ))
841+ .public_key (untrusted_key .public_key ())
842+ .serial_number (x509 .random_serial_number ())
843+ .not_valid_before (not_before )
844+ .not_valid_after (not_after )
845+ .add_extension (x509 .BasicConstraints (ca = True , path_length = None ), critical = True )
846+ .sign (untrusted_key , hashes .SHA256 ())
847+ )
848+ artifacts ["/aia/untrusted.der" ] = ("application/pkix-cert" , untrusted .public_bytes (Encoding .DER ))
849+ untrusted_leaf_key = new_key ()
850+ untrusted_leaf = (
851+ x509 .CertificateBuilder ()
852+ .subject_name (dn ("untrusted-leaf.localhost" ))
853+ .issuer_name (untrusted .subject )
854+ .public_key (untrusted_leaf_key .public_key ())
855+ .serial_number (x509 .random_serial_number ())
856+ .not_valid_before (not_before )
857+ .not_valid_after (not_after )
858+ .add_extension (x509 .SubjectAlternativeName ([x509 .DNSName ("localhost" )]), critical = False )
859+ .add_extension (x509 .ExtendedKeyUsage ([ExtendedKeyUsageOID .SERVER_AUTH ]), critical = False )
860+ .add_extension (
861+ x509 .AuthorityInformationAccess (
862+ [
863+ x509 .AccessDescription (
864+ AuthorityInformationAccessOID .CA_ISSUERS ,
865+ x509 .UniformResourceIdentifier (f"{ aia_base } /untrusted.der" ),
866+ )
867+ ]
868+ ),
869+ critical = False ,
870+ )
871+ .sign (untrusted_key , hashes .SHA256 ())
872+ )
873+ scenarios ["untrusted" ] = (untrusted_leaf , untrusted_leaf_key )
874+
875+ TestHTTPRequestHandler .aia_artifacts = artifacts
876+ TestHTTPRequestHandler .aia_fetch_counts = {}
877+ TestHTTPRequestHandler .aia_lock = threading .Lock ()
878+
879+ # Write the root CA so RequestServer (via --certificate/CAINFO) trusts the completed chains.
880+ with open (ca_cert_output , "wb" ) as ca_file :
881+ ca_file .write (root .public_bytes (Encoding .PEM ))
882+
883+ scratch = tempfile .mkdtemp (prefix = "ladybird-aia-" )
884+ config = {}
885+ for name , (leaf , leaf_key ) in scenarios .items ():
886+ # The cert file holds *only* the leaf — so, each HTTPS host presents a broken chain (the intermediate is omitted
887+ # and must be fetched via AIA).
888+ cert_path = os .path .join (scratch , f"{ name } .crt" )
889+ key_path = os .path .join (scratch , f"{ name } .key" )
890+ with open (cert_path , "wb" ) as f :
891+ f .write (leaf .public_bytes (Encoding .PEM ))
892+ with open (key_path , "wb" ) as f :
893+ f .write (leaf_key .private_bytes (Encoding .PEM , PrivateFormat .TraditionalOpenSSL , NoEncryption ()))
894+ context = ssl .SSLContext (ssl .PROTOCOL_TLS_SERVER )
895+ context .load_cert_chain (certfile = cert_path , keyfile = key_path )
896+ server = AIATLSServer (("127.0.0.1" , 0 ), AIALeafHandler , context )
897+ config [name ] = server .socket .getsockname ()[1 ]
898+ threading .Thread (target = server .serve_forever , daemon = True ).start ()
899+
900+ TestHTTPRequestHandler .aia_config = config
901+ logging .info ("AIA test servers ready: %s (root CA at %s)" , config , ca_cert_output )
902+
903+
904+ def start_server (port , static_directory , ca_cert_output = None ):
640905 TestHTTPRequestHandler .static_directory = os .path .abspath (static_directory )
641906 TestHTTPRequestHandler .wpt_directory = os .path .join (
642907 TestHTTPRequestHandler .static_directory , "Text" , "input" , "wpt-import"
@@ -653,6 +918,12 @@ def start_server(port, static_directory):
653918 url_base = "/static/" ,
654919 )
655920
921+ if ca_cert_output :
922+ try :
923+ _setup_aia_test_servers (httpd .socket .getsockname ()[1 ], ca_cert_output )
924+ except Exception as error :
925+ logging .exception ("AIA test setup failed: %s" , error )
926+
656927 print (httpd .socket .getsockname ()[1 ])
657928 sys .stdout .flush ()
658929
@@ -681,6 +952,12 @@ def start_server(port, static_directory):
681952 default = 0 ,
682953 help = "Port to run the server on" ,
683954 )
955+ parser .add_argument (
956+ "--ca-cert-output" ,
957+ type = str ,
958+ default = None ,
959+ help = "Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers" ,
960+ )
684961 args = parser .parse_args ()
685962
686- start_server (port = args .port , static_directory = args .directory )
963+ start_server (port = args .port , static_directory = args .directory , ca_cert_output = args . ca_cert_output )
0 commit comments