1010import os
1111import socket
1212import socketserver
13+ import ssl
1314import sys
1415import threading
1516import time
@@ -155,6 +156,8 @@ class TestHTTPServer(socketserver.ThreadingTCPServer):
155156class TestHTTPRequestHandler (http .server .SimpleHTTPRequestHandler ):
156157 static_directory : str
157158 wpt_directory : str
159+ aia_artifacts : dict
160+ aia_config : Optional [dict ]
158161
159162 def __init__ (self , * arguments , ** kwargs ):
160163 super ().__init__ (* arguments , directory = self .static_directory , ** kwargs )
@@ -252,6 +255,32 @@ def _serve_wpt_file_request(self):
252255 def _serve_static_request (self ):
253256 self ._serve_wpt_file_request ()
254257
258+ def _serve_aia_artifact (self , request_path ):
259+ # Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL.
260+ # Fetched by RequestServer, not by the page.
261+ entry = getattr (TestHTTPRequestHandler , "aia_artifacts" , {}).get (request_path )
262+ if entry is None :
263+ # for example, /aia/dead: a deliberately missing artifact, to exercise the failure path.
264+ self .send_error (404 , "Not Found" )
265+ return
266+ content_type , body = entry
267+ self .send_response (200 )
268+ self .send_header ("Content-Type" , content_type )
269+ self .send_header ("Content-Length" , str (len (body )))
270+ self .end_headers ()
271+ self .wfile .write (body )
272+
273+ def _serve_aia_config (self ):
274+ # Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port).
275+ config = getattr (TestHTTPRequestHandler , "aia_config" , None )
276+ body = json .dumps (config or {}).encode ("utf-8" )
277+ self .send_response (200 if config else 503 )
278+ self .send_header ("Content-Type" , "application/json" )
279+ self .send_header ("Access-Control-Allow-Origin" , "*" )
280+ self .send_header ("Content-Length" , str (len (body )))
281+ self .end_headers ()
282+ self .wfile .write (body )
283+
255284 def do_GET (self ):
256285 if self .headers .get ("Upgrade" , "" ).lower () == "websocket" :
257286 self ._serve_websocket_echo ()
@@ -267,6 +296,10 @@ def do_GET(self):
267296 self ._serve_recorded_request_headers ()
268297 elif request_path .endswith (".py" ):
269298 self ._serve_wpt_python_script ()
299+ elif request_path .startswith ("/aia/" ):
300+ self ._serve_aia_artifact (request_path )
301+ elif request_path == "/aia-test/config" :
302+ self ._serve_aia_config ()
270303 else :
271304 self ._serve_static_request ()
272305
@@ -636,7 +669,211 @@ def _send_websocket_close(self):
636669 pass
637670
638671
639- def start_server (port , static_directory ):
672+ class AIALeafHandler (http .server .BaseHTTPRequestHandler ):
673+ def do_GET (self ):
674+ self .send_response (200 )
675+ self .send_header ("Content-Type" , "text/plain" )
676+ self .send_header ("Content-Length" , "2" )
677+ self .end_headers ()
678+ self .wfile .write (b"ok" )
679+
680+ def log_message (self , format : str , * args : Any ) -> None :
681+ pass
682+
683+
684+ class AIATLSServer (socketserver .ThreadingTCPServer ):
685+ daemon_threads = True
686+ allow_reuse_address = True
687+
688+ def __init__ (self , server_address , handler , ssl_context ):
689+ super ().__init__ (server_address , handler )
690+ self ._ssl_context = ssl_context
691+
692+ def get_request (self ):
693+ sock , addr = super ().get_request ()
694+ try :
695+ return self ._ssl_context .wrap_socket (sock , server_side = True ), addr
696+ except OSError :
697+ sock .close ()
698+ raise
699+
700+
701+ def _setup_aia_test_servers (http_port , ca_cert_output ):
702+ try :
703+ from cryptography import x509 # type: ignore[import-not-found]
704+ from cryptography .hazmat .primitives import hashes # type: ignore[import-not-found]
705+ from cryptography .hazmat .primitives .asymmetric import ec # type: ignore[import-not-found]
706+ from cryptography .hazmat .primitives .serialization import Encoding # type: ignore[import-not-found]
707+ from cryptography .hazmat .primitives .serialization import NoEncryption # type: ignore[import-not-found]
708+ from cryptography .hazmat .primitives .serialization import PrivateFormat # type: ignore[import-not-found]
709+ from cryptography .hazmat .primitives .serialization import pkcs7 # type: ignore[import-not-found]
710+ from cryptography .x509 .oid import AuthorityInformationAccessOID # type: ignore[import-not-found]
711+ from cryptography .x509 .oid import ExtendedKeyUsageOID # type: ignore[import-not-found]
712+ from cryptography .x509 .oid import NameOID # type: ignore[import-not-found]
713+ except ImportError as error :
714+ logging .warning ("AIA test setup skipped: python 'cryptography' unavailable (%s)" , error )
715+ return
716+
717+ import datetime
718+ import tempfile
719+
720+ def new_key ():
721+ return ec .generate_private_key (ec .SECP256R1 ())
722+
723+ def dn (common_name ):
724+ return x509 .Name ([x509 .NameAttribute (NameOID .COMMON_NAME , common_name )])
725+
726+ not_before , not_after = datetime .datetime (2020 , 1 , 1 ), datetime .datetime (2050 , 1 , 1 )
727+
728+ root_key = new_key ()
729+ root = (
730+ x509 .CertificateBuilder ()
731+ .subject_name (dn ("Ladybird AIA Test Root" ))
732+ .issuer_name (dn ("Ladybird AIA Test Root" ))
733+ .public_key (root_key .public_key ())
734+ .serial_number (x509 .random_serial_number ())
735+ .not_valid_before (not_before )
736+ .not_valid_after (not_after )
737+ .add_extension (x509 .BasicConstraints (ca = True , path_length = None ), critical = True )
738+ .sign (root_key , hashes .SHA256 ())
739+ )
740+
741+ aia_base = f"http://127.0.0.1:{ http_port } /aia"
742+ artifacts = {}
743+ scenarios = {}
744+
745+ def add_scenario (name , host , artifact_name , encoding , shared = None ):
746+ # By default, each scenario gets its own intermediate — so fetching one never populates the cache for another
747+ # (which would mask whether that scenario's own fetch and parse ran). The de-dupe scenarios instead share one
748+ # intermediate — to check it's fetched only once.
749+ if shared is not None :
750+ intermediate , intermediate_key = shared
751+ else :
752+ intermediate_key = new_key ()
753+ intermediate = (
754+ x509 .CertificateBuilder ()
755+ .subject_name (dn (f"Ladybird AIA Test Intermediate ({ name } )" ))
756+ .issuer_name (root .subject )
757+ .public_key (intermediate_key .public_key ())
758+ .serial_number (x509 .random_serial_number ())
759+ .not_valid_before (not_before )
760+ .not_valid_after (not_after )
761+ .add_extension (x509 .BasicConstraints (ca = True , path_length = 0 ), critical = True )
762+ .sign (root_key , hashes .SHA256 ())
763+ )
764+ if encoding == "der" :
765+ artifacts [f"/aia/{ artifact_name } " ] = ("application/pkix-cert" , intermediate .public_bytes (Encoding .DER ))
766+ aia_url = f"{ aia_base } /{ artifact_name } "
767+ elif encoding == "pkcs7" :
768+ artifacts [f"/aia/{ artifact_name } " ] = (
769+ "application/pkcs7-mime" ,
770+ pkcs7 .serialize_certificates ([intermediate ], Encoding .DER ),
771+ )
772+ aia_url = f"{ aia_base } /{ artifact_name } "
773+ else :
774+ aia_url = f"{ aia_base } /dead" # 404: the intermediate is never published
775+
776+ leaf_key = new_key ()
777+ leaf = (
778+ x509 .CertificateBuilder ()
779+ .subject_name (dn (host ))
780+ .issuer_name (intermediate .subject )
781+ .public_key (leaf_key .public_key ())
782+ .serial_number (x509 .random_serial_number ())
783+ .not_valid_before (not_before )
784+ .not_valid_after (not_after )
785+ .add_extension (x509 .SubjectAlternativeName ([x509 .DNSName ("localhost" )]), critical = False )
786+ .add_extension (x509 .ExtendedKeyUsage ([ExtendedKeyUsageOID .SERVER_AUTH ]), critical = False )
787+ .add_extension (
788+ x509 .AuthorityInformationAccess (
789+ [
790+ x509 .AccessDescription (
791+ AuthorityInformationAccessOID .CA_ISSUERS , x509 .UniformResourceIdentifier (aia_url )
792+ )
793+ ]
794+ ),
795+ critical = False ,
796+ )
797+ .sign (intermediate_key , hashes .SHA256 ())
798+ )
799+ scenarios [name ] = (leaf , leaf_key )
800+ return intermediate , intermediate_key
801+
802+ add_scenario ("good_der" , "good-der.localhost" , "intermediate-der.der" , "der" )
803+ add_scenario ("good_pkcs7" , "good-pkcs7.localhost" , "intermediate-pkcs7.p7c" , "pkcs7" )
804+ add_scenario ("dead" , "dead.localhost" , None , "dead" )
805+ shared_intermediate = add_scenario ("dedup_a" , "dedup-a.localhost" , "intermediate-dedup.der" , "der" )
806+ add_scenario ("dedup_b" , "dedup-b.localhost" , "intermediate-dedup.der" , "der" , shared_intermediate )
807+
808+ # Negative trust control: The caIssuers URL serves a self-signed cert that's *not* anchored in the trusted test
809+ # root. A fetched intermediate must never complete a chain to an untrusted anchor — so this host must fail to load.
810+ untrusted_key = new_key ()
811+ untrusted = (
812+ x509 .CertificateBuilder ()
813+ .subject_name (dn ("Ladybird AIA Test UNTRUSTED root" ))
814+ .issuer_name (dn ("Ladybird AIA Test UNTRUSTED root" ))
815+ .public_key (untrusted_key .public_key ())
816+ .serial_number (x509 .random_serial_number ())
817+ .not_valid_before (not_before )
818+ .not_valid_after (not_after )
819+ .add_extension (x509 .BasicConstraints (ca = True , path_length = None ), critical = True )
820+ .sign (untrusted_key , hashes .SHA256 ())
821+ )
822+ artifacts ["/aia/untrusted.der" ] = ("application/pkix-cert" , untrusted .public_bytes (Encoding .DER ))
823+ untrusted_leaf_key = new_key ()
824+ untrusted_leaf = (
825+ x509 .CertificateBuilder ()
826+ .subject_name (dn ("untrusted-leaf.localhost" ))
827+ .issuer_name (untrusted .subject )
828+ .public_key (untrusted_leaf_key .public_key ())
829+ .serial_number (x509 .random_serial_number ())
830+ .not_valid_before (not_before )
831+ .not_valid_after (not_after )
832+ .add_extension (x509 .SubjectAlternativeName ([x509 .DNSName ("localhost" )]), critical = False )
833+ .add_extension (x509 .ExtendedKeyUsage ([ExtendedKeyUsageOID .SERVER_AUTH ]), critical = False )
834+ .add_extension (
835+ x509 .AuthorityInformationAccess (
836+ [
837+ x509 .AccessDescription (
838+ AuthorityInformationAccessOID .CA_ISSUERS ,
839+ x509 .UniformResourceIdentifier (f"{ aia_base } /untrusted.der" ),
840+ )
841+ ]
842+ ),
843+ critical = False ,
844+ )
845+ .sign (untrusted_key , hashes .SHA256 ())
846+ )
847+ scenarios ["untrusted" ] = (untrusted_leaf , untrusted_leaf_key )
848+
849+ TestHTTPRequestHandler .aia_artifacts = artifacts
850+
851+ # Write the root CA so RequestServer (via --certificate/CAINFO) trusts the completed chains.
852+ with open (ca_cert_output , "wb" ) as ca_file :
853+ ca_file .write (root .public_bytes (Encoding .PEM ))
854+
855+ scratch = tempfile .mkdtemp (prefix = "ladybird-aia-" )
856+ config = {}
857+ for name , (leaf , leaf_key ) in scenarios .items ():
858+ # The cert file holds *only* the leaf — so, each HTTPS host presents a broken chain (the intermediate is omitted
859+ # and must be fetched via AIA).
860+ cert_path = os .path .join (scratch , f"{ name } .crt" )
861+ key_path = os .path .join (scratch , f"{ name } .key" )
862+ with open (cert_path , "wb" ) as f :
863+ f .write (leaf .public_bytes (Encoding .PEM ))
864+ with open (key_path , "wb" ) as f :
865+ f .write (leaf_key .private_bytes (Encoding .PEM , PrivateFormat .TraditionalOpenSSL , NoEncryption ()))
866+ context = ssl .SSLContext (ssl .PROTOCOL_TLS_SERVER )
867+ context .load_cert_chain (certfile = cert_path , keyfile = key_path )
868+ server = AIATLSServer (("127.0.0.1" , 0 ), AIALeafHandler , context )
869+ config [name ] = server .socket .getsockname ()[1 ]
870+ threading .Thread (target = server .serve_forever , daemon = True ).start ()
871+
872+ TestHTTPRequestHandler .aia_config = config
873+ logging .info ("AIA test servers ready: %s (root CA at %s)" , config , ca_cert_output )
874+
875+
876+ def start_server (port , static_directory , ca_cert_output = None ):
640877 TestHTTPRequestHandler .static_directory = os .path .abspath (static_directory )
641878 TestHTTPRequestHandler .wpt_directory = os .path .join (
642879 TestHTTPRequestHandler .static_directory , "Text" , "input" , "wpt-import"
@@ -653,6 +890,12 @@ def start_server(port, static_directory):
653890 url_base = "/static/" ,
654891 )
655892
893+ if ca_cert_output :
894+ try :
895+ _setup_aia_test_servers (httpd .socket .getsockname ()[1 ], ca_cert_output )
896+ except Exception as error :
897+ logging .exception ("AIA test setup failed: %s" , error )
898+
656899 print (httpd .socket .getsockname ()[1 ])
657900 sys .stdout .flush ()
658901
@@ -681,6 +924,12 @@ def start_server(port, static_directory):
681924 default = 0 ,
682925 help = "Port to run the server on" ,
683926 )
927+ parser .add_argument (
928+ "--ca-cert-output" ,
929+ type = str ,
930+ default = None ,
931+ help = "Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers" ,
932+ )
684933 args = parser .parse_args ()
685934
686- start_server (port = args .port , static_directory = args .directory )
935+ start_server (port = args .port , static_directory = args .directory , ca_cert_output = args . ca_cert_output )
0 commit comments