emailsec.arc
1from dataclasses import dataclass 2import typing 3import enum 4import re 5 6import emailsec._utils 7from emailsec.dkim.checker import ( 8 _verify_sig, 9 _verify_dkim_signature, 10) 11from emailsec.dkim.parser import ( 12 _algorithm, 13 headers_hash, 14 tag_lists, 15 _DKIMStyleSig, 16 _SigVerifier, 17 _CanonicalizationAlg, 18) 19from emailsec._utils import body_and_headers_for_canonicalization 20 21arc_message_signature = tag_lists 22arc_seal = tag_lists 23 24__all__ = [ 25 "check_arc", 26 "ARCCheck", 27 "ARCChainStatus", 28] 29 30 31class ARCMessageSignature(typing.TypedDict): 32 i: int 33 a: str 34 b: str 35 bh: str 36 c: typing.NotRequired[str] 37 d: str 38 h: str 39 l: typing.NotRequired[int] # noqa: E741 40 q: typing.NotRequired[str] 41 s: str 42 t: typing.NotRequired[int] 43 x: typing.NotRequired[int] 44 z: typing.NotRequired[str] 45 46 47class ARCSeal(typing.TypedDict): 48 i: int 49 a: str 50 b: str 51 d: str 52 s: str 53 cv: str 54 t: typing.NotRequired[int] 55 56 57_ARC_SEAL_REQUIRED_FIELDS = {"i", "a", "b", "d", "s", "cv"} 58_ARC_MSG_SIG_REQUIRED_FIELDS = {"i", "a", "b", "bh", "d", "h", "s"} 59 60 61class ARCChainStatus(enum.StrEnum): 62 NONE = "none" 63 FAIL = "fail" 64 PASS = "pass" 65 66 67@dataclass 68class ARCCheck: 69 result: ARCChainStatus 70 exp: str 71 signer: str | None = None 72 aar_header: bytes | None = None 73 74 75def parse_arc_seal(data: str) -> ARCSeal: 76 sig: ARCSeal = {} # type: ignore 77 for result in arc_seal.parse_string(data, parse_all=True).as_list(): 78 field = result[0] 79 match field: 80 case "a" | "b" | "d" | "s" | "cv": 81 sig[field] = "".join(re.split(r"\s+", result[1])) 82 case "t" | "i": 83 try: 84 sig[field] = int(result[1]) 85 except ValueError as ve: 86 raise ValueError(f"Invalid field value {result=}") from ve 87 case "h": 88 # https://datatracker.ietf.org/doc/html/rfc8617#section-4.1.3 89 # must fail if h tag is found in seal 90 raise ValueError("h tag not allowed") 91 case _: 92 continue 93 if ( 94 missing_fields := set(sig.keys()) & _ARC_SEAL_REQUIRED_FIELDS 95 ) != _ARC_SEAL_REQUIRED_FIELDS: 96 raise ValueError(f"Missing required fields {missing_fields=}") 97 98 return sig 99 100 101def parse_arc_message_signature(data: str) -> ARCMessageSignature: 102 sig: ARCMessageSignature = {} # type: ignore 103 for result in arc_message_signature.parse_string(data, parse_all=True).as_list(): 104 field = result[0] 105 match field: 106 case "a" | "b" | "bh" | "c" | "d" | "h" | "q" | "s" | "z": 107 sig[field] = "".join(re.split(r"\s+", result[1])) 108 case "l" | "t" | "x" | "i": 109 try: 110 sig[field] = int(result[1]) 111 except ValueError as ve: 112 raise ValueError(f"Invalid field value {result=}") from ve 113 case _: 114 continue 115 116 if missing_fields := _ARC_MSG_SIG_REQUIRED_FIELDS - set(sig.keys()): 117 raise ValueError(f"Missing required fields {missing_fields=}") 118 119 return sig 120 121 122async def arc_seal_verify( 123 arc_set_headers: tuple[ 124 emailsec._utils.Header, emailsec._utils.Header, emailsec._utils.Header 125 ], 126 sig: ARCSeal, 127) -> bool: 128 header_canonicalization: _CanonicalizationAlg = "relaxed" 129 dkim_alg = _algorithm(sig["a"]) 130 131 # headers ordering: aar_header, ams_header, seal_header 132 headers_to_sign = list(arc_set_headers[:2]) 133 # the ARC-Seal is treated differently as the body hash needs to be stripped 134 sig_header = arc_set_headers[-1] 135 canonicalized_message = headers_hash( 136 headers_to_sign, 137 header_canonicalization, 138 sig_header, 139 ) 140 return await _verify_sig( 141 dkim_alg, typing.cast(_SigVerifier, sig), canonicalized_message 142 ) 143 144 145_ARC_INSTANCE = re.compile(rb"\s?i\s*=\s*(\d+)", re.MULTILINE | re.IGNORECASE) 146 147 148def _aar_instance(header_value: bytes) -> int: 149 if (match := re.search(_ARC_INSTANCE, header_value)) is not None: 150 return int(match.group(1)) 151 152 raise ValueError(f"Instance not found in {header_value=}") 153 154 155async def check_arc( 156 message: bytes, body_and_headers: emailsec._utils.BodyAndHeaders | None = None 157) -> ARCCheck: 158 if body_and_headers: 159 body, headers = body_and_headers 160 else: 161 body, headers = body_and_headers_for_canonicalization(message) 162 163 arc_message_signatures = headers.get("arc-message-signature") 164 if not arc_message_signatures: 165 return ARCCheck(ARCChainStatus.NONE, "No ARC Sets") 166 arc_authentication_results = headers.get("arc-authentication-results", []) 167 arc_seals = headers.get("arc-seal", []) 168 169 if not ( 170 len(arc_message_signatures) == len(arc_authentication_results) == len(arc_seals) 171 ): 172 return ARCCheck(ARCChainStatus.FAIL, "Uneven ARC Sets") 173 174 if len(arc_authentication_results) > 50: 175 return ARCCheck(ARCChainStatus.FAIL, "Too many ARC Sets") 176 177 parsed_ams = sorted( 178 ( 179 ( 180 parse_arc_message_signature(value.decode()), 181 (header_name, value), 182 ) 183 for header_name, value in headers["arc-message-signature"] 184 ), 185 key=lambda x: x[0]["i"], 186 ) 187 parsed_as = sorted( 188 ( 189 ( 190 parse_arc_seal(value.decode()), 191 (header_name, value), 192 ) 193 for header_name, value in headers["arc-seal"] 194 ), 195 key=lambda x: x[0]["i"], 196 ) 197 aars = sorted( 198 ( 199 ( 200 _aar_instance(value), 201 (header_name, value), 202 ) 203 for header_name, value in headers["arc-authentication-results"] 204 ), 205 key=lambda x: x[0], 206 ) 207 208 highest_validated_aar = None 209 highest_validated_signer = None 210 211 for instance in range(len(arc_message_signatures), 0, -1): 212 ams, ams_header = parsed_ams.pop() 213 if ams["i"] != instance: 214 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AMS for {instance=}") 215 216 seal, seal_header = parsed_as.pop() 217 if seal["i"] != instance: 218 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AS for {instance=}") 219 220 aar_instance, aar_header = aars.pop() 221 if aar_instance != instance: 222 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AAR for {instance=}") 223 224 if instance == 1 and seal["cv"] != "none": 225 return ARCCheck(ARCChainStatus.FAIL, f"AMS cv must be none for {instance=}") 226 elif instance > 1 and seal["cv"] != "pass": 227 return ARCCheck(ARCChainStatus.FAIL, f"AMS cv fail for {instance=}") 228 229 is_ams_valid = await _verify_dkim_signature( 230 body, headers, ams_header, typing.cast(_DKIMStyleSig, ams) 231 ) 232 if not is_ams_valid: 233 return ARCCheck(ARCChainStatus.FAIL, f"Cannot verify AMS for {instance=}") 234 235 arc_set_headers = (aar_header, ams_header, seal_header) 236 237 is_seal_valid = await arc_seal_verify(arc_set_headers, seal) 238 if not is_seal_valid: 239 return ARCCheck(ARCChainStatus.FAIL, f"Cannot verify AS for {instance=}") 240 241 if highest_validated_aar is None: 242 highest_validated_aar = aar_header[1] 243 highest_validated_signer = seal["d"] 244 245 return ARCCheck( 246 ARCChainStatus.PASS, 247 "", 248 signer=highest_validated_signer, 249 aar_header=highest_validated_aar, 250 )
156async def check_arc( 157 message: bytes, body_and_headers: emailsec._utils.BodyAndHeaders | None = None 158) -> ARCCheck: 159 if body_and_headers: 160 body, headers = body_and_headers 161 else: 162 body, headers = body_and_headers_for_canonicalization(message) 163 164 arc_message_signatures = headers.get("arc-message-signature") 165 if not arc_message_signatures: 166 return ARCCheck(ARCChainStatus.NONE, "No ARC Sets") 167 arc_authentication_results = headers.get("arc-authentication-results", []) 168 arc_seals = headers.get("arc-seal", []) 169 170 if not ( 171 len(arc_message_signatures) == len(arc_authentication_results) == len(arc_seals) 172 ): 173 return ARCCheck(ARCChainStatus.FAIL, "Uneven ARC Sets") 174 175 if len(arc_authentication_results) > 50: 176 return ARCCheck(ARCChainStatus.FAIL, "Too many ARC Sets") 177 178 parsed_ams = sorted( 179 ( 180 ( 181 parse_arc_message_signature(value.decode()), 182 (header_name, value), 183 ) 184 for header_name, value in headers["arc-message-signature"] 185 ), 186 key=lambda x: x[0]["i"], 187 ) 188 parsed_as = sorted( 189 ( 190 ( 191 parse_arc_seal(value.decode()), 192 (header_name, value), 193 ) 194 for header_name, value in headers["arc-seal"] 195 ), 196 key=lambda x: x[0]["i"], 197 ) 198 aars = sorted( 199 ( 200 ( 201 _aar_instance(value), 202 (header_name, value), 203 ) 204 for header_name, value in headers["arc-authentication-results"] 205 ), 206 key=lambda x: x[0], 207 ) 208 209 highest_validated_aar = None 210 highest_validated_signer = None 211 212 for instance in range(len(arc_message_signatures), 0, -1): 213 ams, ams_header = parsed_ams.pop() 214 if ams["i"] != instance: 215 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AMS for {instance=}") 216 217 seal, seal_header = parsed_as.pop() 218 if seal["i"] != instance: 219 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AS for {instance=}") 220 221 aar_instance, aar_header = aars.pop() 222 if aar_instance != instance: 223 return ARCCheck(ARCChainStatus.FAIL, f"Cannot find AAR for {instance=}") 224 225 if instance == 1 and seal["cv"] != "none": 226 return ARCCheck(ARCChainStatus.FAIL, f"AMS cv must be none for {instance=}") 227 elif instance > 1 and seal["cv"] != "pass": 228 return ARCCheck(ARCChainStatus.FAIL, f"AMS cv fail for {instance=}") 229 230 is_ams_valid = await _verify_dkim_signature( 231 body, headers, ams_header, typing.cast(_DKIMStyleSig, ams) 232 ) 233 if not is_ams_valid: 234 return ARCCheck(ARCChainStatus.FAIL, f"Cannot verify AMS for {instance=}") 235 236 arc_set_headers = (aar_header, ams_header, seal_header) 237 238 is_seal_valid = await arc_seal_verify(arc_set_headers, seal) 239 if not is_seal_valid: 240 return ARCCheck(ARCChainStatus.FAIL, f"Cannot verify AS for {instance=}") 241 242 if highest_validated_aar is None: 243 highest_validated_aar = aar_header[1] 244 highest_validated_signer = seal["d"] 245 246 return ARCCheck( 247 ARCChainStatus.PASS, 248 "", 249 signer=highest_validated_signer, 250 aar_header=highest_validated_aar, 251 )
@dataclass
class
ARCCheck:
68@dataclass 69class ARCCheck: 70 result: ARCChainStatus 71 exp: str 72 signer: str | None = None 73 aar_header: bytes | None = None
ARCCheck( result: ARCChainStatus, exp: str, signer: str | None = None, aar_header: bytes | None = None)
result: ARCChainStatus
class
ARCChainStatus(enum.StrEnum):
NONE =
<ARCChainStatus.NONE: 'none'>
FAIL =
<ARCChainStatus.FAIL: 'fail'>
PASS =
<ARCChainStatus.PASS: 'pass'>