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    )
async def check_arc( message: bytes, body_and_headers: BodyAndHeaders | None = None) -> ARCCheck:
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
exp: str
signer: str | None = None
aar_header: bytes | None = None
class ARCChainStatus(enum.StrEnum):
62class ARCChainStatus(enum.StrEnum):
63    NONE = "none"
64    FAIL = "fail"
65    PASS = "pass"
NONE = <ARCChainStatus.NONE: 'none'>
FAIL = <ARCChainStatus.FAIL: 'fail'>
PASS = <ARCChainStatus.PASS: 'pass'>