diff --git a/changelog/70041.fixed.md b/changelog/70041.fixed.md new file mode 100644 index 00000000000..38caf4649bd --- /dev/null +++ b/changelog/70041.fixed.md @@ -0,0 +1 @@ +Fixed handling of several `x509_v2` GeneralNames: nameConstraints URI/IP definitions, encoding of URI path segments with non-ASCII characters, URI IPv6 hostnames, URI without authority/scheme, DNSNames with non-standard wildcards, and others. diff --git a/changelog/70042.fixed.md b/changelog/70042.fixed.md new file mode 100644 index 00000000000..033cd79c260 --- /dev/null +++ b/changelog/70042.fixed.md @@ -0,0 +1 @@ +Fixed handling of `x509_v2` `basicConstraints` `pathlen` when issuer certificate has an explicit `pathlen`: We now validate the requested `pathlen` against the issuer certificate and default it to one lower if unspecified diff --git a/changelog/70046.fixed.md b/changelog/70046.fixed.md new file mode 100644 index 00000000000..d6d91abc11a --- /dev/null +++ b/changelog/70046.fixed.md @@ -0,0 +1 @@ +Made `salt.utils.x509.load_pubkey`'s `get_encoding` parameter work as expected diff --git a/salt/utils/x509.py b/salt/utils/x509.py index 54dd893d39e..d439a5834cf 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -7,7 +7,7 @@ from collections import OrderedDict from datetime import datetime, timedelta, timezone from enum import Enum -from urllib.parse import urlparse, urlunparse +from urllib.parse import quote, urlsplit, urlunsplit import cryptography from cryptography import x509 as cx509 @@ -448,7 +448,7 @@ def build_csr(private_key, private_key_passphrase=None, subject=None, **kwargs): builder = cx509.CertificateSigningRequestBuilder() subject_name = _get_dn(subject or kwargs) builder = builder.subject_name(subject_name) - for extname, oid in EXTENSIONS_OID.items(): + for extname, _ in EXTENSIONS_OID.items(): if any( ( extname not in CERT_EXTS, @@ -730,6 +730,7 @@ def to_der(pub_or_cert): def load_privkey(pk, passphrase=None, get_encoding=False): """ Return a private key instance from + * a class instance * a file path on the local system * a string (PEM) @@ -818,6 +819,7 @@ def load_privkey(pk, passphrase=None, get_encoding=False): def load_pubkey(pk, get_encoding=False): """ Return a public key instance from + * a class instance * a file path on the local system * a string (PEM) @@ -842,15 +844,21 @@ def load_pubkey(pk, get_encoding=False): pk = load_file_or_bytes(pk) if PEM_BEGIN in pk: try: - return serialization.load_pem_public_key(pk) + ret = serialization.load_pem_public_key(pk) except ValueError as err: raise PubDeserializationError( "Could not load PEM-encoded public key." ) from err + if get_encoding: + return ret, "pem" + return ret try: - return serialization.load_der_public_key(pk) + ret = serialization.load_der_public_key(pk) except ValueError as err: raise PubDeserializationError("Could not load DER-encoded public key.") from err + if get_encoding: + return ret, "der" + return ret def order_certs_naively(bundle, allow_orphans=True, require_leaf=True): @@ -948,6 +956,7 @@ def _paths_from( def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False): """ Return a certificate instance from + * a class instance * a file path on the local system * a string (PEM) @@ -1244,7 +1253,7 @@ def _create_extension(name, val, subject_pubkey=None, ca_crt=None, ca_pub=None): ) -def _create_basic_constraints(val, **kwargs): +def _create_basic_constraints(val, ca_crt, **_): try: critical = val.get("critical", False) except AttributeError: @@ -1259,6 +1268,23 @@ def _create_basic_constraints(val, **kwargs): raise SaltInvocationError( f"Invalid configuration for basicContraints: {err}" ) from err + if val.get("ca") and ca_crt: + try: + ca_bc = ca_crt.extensions.get_extension_for_class(cx509.BasicConstraints) + except cx509.ExtensionNotFound: + pass + else: + if ca_bc.value.path_length is not None: + if ( + val.get("pathlen") is not None + and ca_bc.value.path_length <= val["pathlen"] + ): + raise CommandExecutionError( + f"Issuing CA certificate has pathlen {ca_bc.value.path_length}, " + f"which is less than or equal to requested pathlen of {val['pathlen']}" + ) + if val.get("pathlen") is None: + val["pathlen"] = ca_bc.value.path_length - 1 try: return ( cx509.BasicConstraints(val["ca"], val.get("pathlen")), @@ -1272,7 +1298,7 @@ def _create_basic_constraints(val, **kwargs): raise SaltInvocationError(err) from err -def _create_key_usage(val, **kwargs): +def _create_key_usage(val, **_): critical = "critical" in val args = { "digital_signature": "digitalSignature" in val, @@ -1291,7 +1317,7 @@ def _create_key_usage(val, **kwargs): raise SaltInvocationError(err) from err -def _create_extended_key_usage(val, **kwargs): +def _create_extended_key_usage(val, **_): critical = "critical" in val if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1306,7 +1332,7 @@ def _create_extended_key_usage(val, **kwargs): return cx509.ExtendedKeyUsage(usages), critical -def _create_subject_key_identifier(val, subject_pubkey, **kwargs): +def _create_subject_key_identifier(val, subject_pubkey, **_): if "critical" in val: raise SaltInvocationError("subjectKeyIdentifier must be marked as non-critical") if val == "hash": @@ -1334,7 +1360,7 @@ def _create_subject_key_identifier(val, subject_pubkey, **kwargs): return cx509.SubjectKeyIdentifier(val), False -def _create_authority_key_identifier(val, ca_crt, ca_pub, **kwargs): +def _create_authority_key_identifier(val, ca_crt, ca_pub, **_): if "critical" in val: raise SaltInvocationError( "authorityKeyIdentifier must be marked as non-critical" @@ -1401,12 +1427,12 @@ def _create_authority_key_identifier(val, ca_crt, ca_pub, **kwargs): return cx509.AuthorityKeyIdentifier(**args), False -def _create_issuer_alt_name(val, ca_crt, **kwargs): +def _create_issuer_alt_name(val, ca_crt, **_): parsed, critical = _parse_issuer_general_name(val, ca_crt) return cx509.IssuerAlternativeName(parsed), critical -def _create_certificate_issuer(val, ca_crt, **kwargs): +def _create_certificate_issuer(val, ca_crt, **_): parsed, critical = _parse_issuer_general_name(val, ca_crt) return cx509.CertificateIssuer(parsed), critical @@ -1447,11 +1473,11 @@ def _parse_issuer_general_name(val, ca_crt): "It seems your version of cryptography does not have an " "internal API that the issuer:copy functionality relies on" ) from err - parsed.extend(_parse_general_names(val)) + parsed.extend(parse_general_names(val)) return parsed, critical -def _create_authority_info_access(val, **kwargs): +def _create_authority_info_access(val, **_): if isinstance(val, str): val = (x.strip().split(";") for x in val.split(",") if x.strip() != "critical") elif isinstance(val, dict): @@ -1470,7 +1496,7 @@ def _create_authority_info_access(val, **kwargs): return cx509.AuthorityInformationAccess(parsed), False # always noncritical -def _create_subject_alt_name(val, **kwargs): +def _create_subject_alt_name(val, **_): # Note: subjectAltName must be marked as critical if subject is empty. # This is not checked. critical = "critical" in val @@ -1485,16 +1511,16 @@ def _create_subject_alt_name(val, **kwargs): val = tuple(list_) elif isinstance(val, str): val, critical = _deserialize_openssl_confstring(val, multiple=True) - parsed = _parse_general_names(val) + parsed = parse_general_names(val) return cx509.SubjectAlternativeName(parsed), critical -def _create_crl_distribution_points(val, **kwargs): +def _create_crl_distribution_points(val, **_): parsed, critical = _parse_distribution_points(val) return cx509.CRLDistributionPoints(parsed), critical -def _create_freshest_crl(val, **kwargs): +def _create_freshest_crl(val, **_): parsed, _ = _parse_distribution_points(val) return cx509.FreshestCRL(parsed), False # must be non-critical @@ -1514,7 +1540,7 @@ def _parse_distribution_points(val): val = tuple(list_) parsed = [] for dpoint in val: - fullname = relativename = crlissuer = reasons = None + relativename = crlissuer = reasons = None if isinstance(dpoint, dict): fullname = dpoint.get("fullname") relativename = dpoint.get("relativename") @@ -1529,7 +1555,7 @@ def _parse_distribution_points(val): if crlissuer: if not isinstance(crlissuer, list): crlissuer = [crlissuer] - crlissuer = _parse_general_names( + crlissuer = parse_general_names( x.split(":", maxsplit=1) for x in crlissuer ) if reasons: @@ -1540,7 +1566,7 @@ def _parse_distribution_points(val): else: fullname = (dpoint,) if fullname: - fullname = _parse_general_names(fullname) + fullname = parse_general_names(fullname) try: parsed.append( cx509.DistributionPoint( @@ -1555,7 +1581,7 @@ def _parse_distribution_points(val): return parsed, critical -def _create_issuing_distribution_point(val, **kwargs): +def _create_issuing_distribution_point(val, **_): if not isinstance(val, dict): raise SaltInvocationError("issuingDistributionPoint must be a dictionary") critical = val.get("critical", False) @@ -1570,7 +1596,7 @@ def _create_issuing_distribution_point(val, **kwargs): if not isinstance(fullname, list): fullname = [fullname] fullname = (x.split(":", maxsplit=1) for x in fullname) - fullname = _parse_general_names(fullname) + fullname = parse_general_names(fullname) if relativename: relativename = _get_rdn(relativename) if onlysomereasons: @@ -1595,7 +1621,7 @@ def _create_issuing_distribution_point(val, **kwargs): raise SaltInvocationError(err) from err -def _create_certificate_policies(val, **kwargs): +def _create_certificate_policies(val, **_): if isinstance(val, str): try: critical = val.startswith("critical") @@ -1623,7 +1649,6 @@ def _create_certificate_policies(val, **kwargs): # pointer to the practice statement published by the certificate authority parsed_qualifiers.append(qual) continue - notice = None organization = qual.get("organization") notice_numbers = qual.get("noticeNumbers") text = qual.get("text") @@ -1646,7 +1671,7 @@ def _create_certificate_policies(val, **kwargs): return cx509.CertificatePolicies(parsed), critical -def _create_policy_constraints(val, **kwargs): +def _create_policy_constraints(val, **_): critical = "critical" in val if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1668,7 +1693,7 @@ def _create_policy_constraints(val, **kwargs): raise SaltInvocationError(err) from err -def _create_inhibit_any_policy(val, **kwargs): +def _create_inhibit_any_policy(val, **_): critical = "critical" in val if not isinstance(val, int) else False if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1686,7 +1711,7 @@ def _create_inhibit_any_policy(val, **kwargs): raise SaltInvocationError(err) from err -def _create_name_constraints(val, **kwargs): +def _create_name_constraints(val, **_): critical = "critical" in val if isinstance(val, dict): parsed = {} @@ -1714,10 +1739,14 @@ def _create_name_constraints(val, **kwargs): } args = { "permitted_subtrees": ( - _parse_general_names(val["permitted"]) if "permitted" in val else None + parse_general_names(val["permitted"], name_constraints=True) + if "permitted" in val + else None ), "excluded_subtrees": ( - _parse_general_names(val["excluded"]) if "excluded" in val else None + parse_general_names(val["excluded"], name_constraints=True) + if "excluded" in val + else None ), } if not any(args.values()): @@ -1725,11 +1754,11 @@ def _create_name_constraints(val, **kwargs): return cx509.NameConstraints(**args), critical -def _create_no_check(val, **kwargs): +def _create_no_check(val, **_): return cx509.OCSPNoCheck(), "critical" in str(val) -def _create_tlsfeature(val, **kwargs): +def _create_tlsfeature(val, **_): if isinstance(val, str): val = [x.strip() for x in val.split(",")] critical = "critical" in val @@ -1740,15 +1769,15 @@ def _create_tlsfeature(val, **kwargs): return cx509.TLSFeature(types), critical -def _create_ns_comment(val, **kwargs): +def _create_ns_comment(val, **_): raise SaltInvocationError("nsComment is currently not implemented.") -def _create_ns_cert_type(val, **kwargs): +def _create_ns_cert_type(val, **_): raise SaltInvocationError("nsCertType is currently not implemented.") -def _create_crl_number(val, **kwargs): +def _create_crl_number(val, **_): try: return cx509.CRLNumber(int(val)), False except ValueError as err: @@ -1757,7 +1786,7 @@ def _create_crl_number(val, **kwargs): ) from err -def _create_delta_crl_indicator(val, **kwargs): +def _create_delta_crl_indicator(val, **_): critical = "critical" in str(val) val = re.findall(r"[\d]+", str(val)) if len(val) != 1: @@ -1767,7 +1796,7 @@ def _create_delta_crl_indicator(val, **kwargs): return cx509.DeltaCRLIndicator(int(val[0])), critical -def _create_crl_reason(val, **kwargs): +def _create_crl_reason(val, **_): critical = False if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1782,7 +1811,7 @@ def _create_crl_reason(val, **kwargs): raise SaltInvocationError(str(err)) from err -def _create_invalidity_date(val, **kwargs): +def _create_invalidity_date(val, **_): if not isinstance(val, str): raise SaltInvocationError("invalidityDate must be a string") critical = val.startswith("critical") @@ -1946,63 +1975,166 @@ def _parse_other_name(value): ) -def _parse_general_names(val): - def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): - # A leading dot is allowed in some values (nameConstraints). - # idna complains about it not being a valid domain name +def _validate_dns_label(label, *, allow_wildcard=False): + """ + Reject strings that are not valid ASCII DNS labels. + """ + if not label: + raise CommandExecutionError("Empty Label") + label.encode(encoding="ascii") # ensure only ASCII chars + allowed = r"A-Za-z\d\-" + if allow_wildcard: + allowed += r"\*" + invalid = re.search(f"[^{allowed}]", label) + if invalid is not None: + raise CommandExecutionError( + f"Codepoint U+00{ord(invalid.group()):02X} at position {invalid.end()} of '{label}' not allowed" + ) + if label[0] == "-" or label[-1] == "-": + raise CommandExecutionError("Label must not start or end with a hyphen") + if len(label.replace("*", "") if allow_wildcard else label) > 63: + raise CommandExecutionError("Label too long") + + +def _validate_dns_name(dns_name, *, allow_wildcard=False, allow_trailing_dot=False): + """ + Reject strings that are not valid ASCII DNS domains. + """ + if not dns_name: + raise CommandExecutionError("Empty domain") + dns_name.encode( + encoding="ascii" + ) # ensure only ASCII chars, including label separators + labels = dns_name.split(".") + if allow_trailing_dot and not labels[-1]: + labels.pop() + for label in labels: + _validate_dns_label(label, allow_wildcard=allow_wildcard) + + +def idna_encode(domain, *, allow_leading_dot=False, allow_trailing_dot=False): + """ + Encode a domain that might contain unicode characters into punycode, as per IDNA. + + domain + Value to encode. + + allow_leading_dot + Allow DNSNames like ``.example.com``, as seen e.g. in nameConstraints. + """ + # A leading dot is allowed in some values (nameConstraints). + # idna complains about it not being a valid domain name + try: + leading_dot = domain[0] in ("\u002e", "\u3002", "\uff0e", "\uff61") + trailing_dot = domain[-1] in ("\u002e", "\u3002", "\uff0e", "\uff61") + except (KeyError, TypeError): + raise CommandExecutionError( + f"Expected string value, got {type(domain).__name__}: `{domain!r}`" + ) + except IndexError: + raise CommandExecutionError("Empty domain") + if trailing_dot and not allow_trailing_dot: + raise CommandExecutionError("Trailing dots are not allowed in this context") + if leading_dot: + if not allow_leading_dot: + raise CommandExecutionError("Leading dots are not allowed in this context") + domain = domain[1:] + if "*" in domain: + raise CommandExecutionError("Wildcards are not allowed in this context") + if HAS_IDNA: try: - has_dot = val.startswith(".") - except AttributeError: - raise SaltInvocationError( - f"Expected string value, got {type(val).__name__}: `{val}`" - ) - if has_dot: - if not allow_leading_dot: - raise CommandExecutionError( - "Leading dots are not allowed in this context" - ) - val = val.lstrip(".") - has_wildcard = val.startswith("*.") - if has_wildcard: - if not allow_wildcard: - raise CommandExecutionError("Wildcards are not allowed in this context") - if has_dot: - raise CommandExecutionError( - "Wildcards and leading dots cannot be present together" - ) - val = val[2:] - if val.startswith("."): - raise CommandExecutionError("Empty label") - if HAS_IDNA: + ret = idna.encode(domain).decode() + except idna.IDNAError as err: + raise CommandExecutionError(str(err)) from err + else: + try: + _validate_dns_name(domain, allow_trailing_dot=allow_trailing_dot) + except UnicodeEncodeError as err: + raise CommandExecutionError( + "Cannot encode non-ASCII strings to internationalized domain " + "name format, missing library: idna" + ) from err + if len(domain) > (254 if trailing_dot else 253): + raise CommandExecutionError("Domain too long") + ret = domain + if leading_dot: + return f".{ret}" + return ret + + +def idna_encode_with_wildcard(domain: str, *, allow_trailing_dot=False): + """ + Encode a domain that might contain unicode characters into punycode, as per IDNA. + Unlike ``idna_encode``, labels that contain a wildcard character are allowed. + These labels must consist entirely of valid ASCII DNS-label characters; + any internationalized portions must already be IDNA-encoded. + + domain + Value to encode. + """ + if not domain: + raise CommandExecutionError("Empty domain") + try: + labels = re.split("[\u002e\u3002\uff0e\uff61]", domain) + except TypeError: + raise SaltInvocationError( + f"Expected string value, got {type(domain).__name__}: `{domain!r}`" + ) + if trailing_dot := not labels[-1]: + if not allow_trailing_dot: + raise CommandExecutionError("Trailing dots are not allowed in this context") + labels.pop() + if labels[0] == "": + raise CommandExecutionError("Leading dots are not allowed in this context") + encoded = [] + for label in labels: + if "*" in label: try: - ret = idna.encode(val).decode() - except idna.IDNAError as err: - raise CommandExecutionError(str(err)) from err - else: - if not val: - raise CommandExecutionError("Empty domain") + _validate_dns_label(label, allow_wildcard=True) + except UnicodeEncodeError as err: + raise CommandExecutionError( + "Label with wildcard must contain ASCII characters only; " + "internationalized portions must already be IDNA-encoded" + ) from err + encoded.append(label) + elif not HAS_IDNA: try: - val.encode(encoding="ascii") + _validate_dns_label(label) except UnicodeEncodeError as err: raise CommandExecutionError( "Cannot encode non-ASCII strings to internationalized domain " "name format, missing library: idna" ) from err - for elem in val.split("."): - if not elem: - raise CommandExecutionError("Empty Label") - invalid = re.search(r"[^A-Za-z\d\-\.]", elem) - if invalid is not None: - raise CommandExecutionError( - f"Codepoint U+00{hex(ord(invalid.group()))[2:]} at position {invalid.end()} of '{val}' not allowed" - ) - ret = val - if has_dot: - return f".{ret}" - if has_wildcard: - return f"*.{ret}" - return ret + encoded.append(label) + else: + try: + alabel = idna.alabel(label) + except idna.IDNAError as err: + raise CommandExecutionError(str(err)) from err + encoded.append(alabel.decode()) + if trailing_dot: + encoded.append("") + ret = ".".join(encoded) + if len(ret.replace("*", "")) > (254 if trailing_dot else 253): + raise CommandExecutionError("Domain too long") + return ret + + +def parse_general_names(val, *, name_constraints=False): + """ + Hydrate a list of General Name definition tuples of ``(type, value)`` into + cryptography objects. + + val + List of 2-tuples. Each tuple is of the form ``(, )``, where ```` + is one of ``email``, ``uri``, ``dns``, ``rid``, ``ip``, ``dirname`` or ``othername``. + ```` is case-insensitive. + name_constraints + Indicate that the list of GNs is intended for the ``nameConstraints`` extension, which has + specific requirements (e.g. IP networks instead of addresses, allows leading dot in + domain names, but no wildcards). Defaults to false. + """ valid_types = { "email": cx509.general_name.RFC822Name, "uri": cx509.general_name.UniformResourceIdentifier, @@ -2013,44 +2145,139 @@ def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): "othername": _parse_other_name, } + def _encode_domain( + domain, wildcards=False, nc_leading_dot=True, trailing_dot=False + ): + if name_constraints: + return idna_encode(domain, allow_leading_dot=nc_leading_dot) + if wildcards and "*" in str(domain): + return idna_encode_with_wildcard(domain, allow_trailing_dot=trailing_dot) + return idna_encode(domain, allow_trailing_dot=trailing_dot) + parsed = [] for typ, v in val: typ = typ.lower() if typ == "dirname": - v = _get_dn(v) + res = _get_dn(v) elif typ == "rid": - v = _get_oid(v) + res = _get_oid(v) elif typ == "ip": try: - v = ipaddress.ip_address(v) - except ValueError: + if name_constraints: + res = ipaddress.ip_network(v) + else: + res = ipaddress.ip_address(v) + except ValueError as err: + raise CommandExecutionError( + f"Provided value {v!r} does not seem to be an IPv4/IPv6 {'network range' if name_constraints else 'address'}." + ) from err + elif typ == "email": + try: + has_user = "@" in v + except TypeError as err: + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + if has_user: + user, domain = v.rsplit("@", maxsplit=1) try: - v = ipaddress.ip_network(v) - except ValueError as err: + user.encode("ascii") + except UnicodeEncodeError as err: raise CommandExecutionError( - f"Provided value {v} does not seem to be an IP address or network range." + "Email address username must not contain non-ASCII chars, use SmtpUTF8Mailbox otherName instead" ) from err - elif typ == "email": - splits = v.rsplit("@", maxsplit=1) - if len(splits) > 1: - user, domain = splits - domain = idna_encode(domain) - v = "@".join((user, domain)) + elif not name_constraints: + raise CommandExecutionError(f"Not a valid email in this context: {v}") else: - # nameConstraints - v = idna_encode(splits[0], allow_leading_dot=True) + user, domain = None, v + domain = _encode_domain(domain, nc_leading_dot=user is None) + res = domain if user is None else f"{user}@{domain}" elif typ == "uri": - url = urlparse(v) - if url.netloc: - domain = idna_encode(url.netloc) - v = urlunparse( - (url.scheme, domain, url.path, url.params, url.query, url.fragment) + if ( + name_constraints + ): # A URI in nameConstraints is parsed exactly like a DNSName + try: + res = _encode_domain(v) + except CommandExecutionError as err: + # Friendlier error message for https://foo.bar etc. + # Cannot check this before because .foo.bar - allowed in NameConstraints - is parsed as a path, not a netloc + try: + url = urlsplit(v) + except (AttributeError, TypeError, ValueError): + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + if url.scheme: + raise CommandExecutionError( + f"NameConstraints URI should be the same format as DNS, not a full URI. Got: {v}" + ) from err + if "*" in v: + raise CommandExecutionError( + "Wildcards are not allowed in this context" + ) + raise + else: + try: + if re.search(r"%(?![0-9A-Fa-f]{2})", v): + raise CommandExecutionError( + f"Invalid percent-encoding in URI: {v}" + ) + except TypeError as err: + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + url = urlsplit(v) + if not url.scheme: + if v.startswith("."): + raise CommandExecutionError( + "Leading dots are not allowed in this context" + ) + raise CommandExecutionError("URI must contain a scheme") + + netloc = url.netloc + if hostname := url.hostname: + try: + ip = ipaddress.ip_address(hostname) + except ValueError: + host = _encode_domain( + hostname, wildcards=True, trailing_dot=True + ) + else: + host = f"[{ip}]" if ip.version == 6 else str(ip) + try: + port = url.port + except ValueError as err: + raise CommandExecutionError(str(err)) from err + if port is not None: + host = f"{host}:{port}" + + if url.username is not None: + userinfo = url.username + if url.password is not None: + userinfo += f":{url.password}" + userinfo = quote(userinfo, safe="!$&'()*+,;=:%") + netloc = f"{userinfo}@{host}" + else: + netloc = host + + # Also convert IRI to URI. % is safe since we already validated all of them, just pass through + safe_chars = "/:@!$&'()*+,;=%" + res = urlunsplit( + ( + url.scheme, + netloc, + quote(url.path, safe=safe_chars), + quote(url.query, safe=safe_chars + "?"), + quote(url.fragment, safe=safe_chars + "?"), + ) ) elif typ == "dns": - v = idna_encode(v, allow_leading_dot=True, allow_wildcard=True) + res = _encode_domain(v, wildcards=True) + else: + res = v if typ in valid_types: try: - parsed.append(valid_types[typ](v)) + parsed.append(valid_types[typ](res)) continue except (ValueError, TypeError) as err: raise CommandExecutionError(err) from err @@ -2082,7 +2309,7 @@ def _get_rdn(rdn): def _get_gn(gn): - return _parse_general_names((gn.split(":", maxsplit=1),))[0] + return parse_general_names((gn.split(":", maxsplit=1),))[0] def _get_serial_number(sn=None): diff --git a/tests/pytests/functional/modules/test_x509_v2.py b/tests/pytests/functional/modules/test_x509_v2.py index 394b34987e0..f36d06d51d9 100644 --- a/tests/pytests/functional/modules/test_x509_v2.py +++ b/tests/pytests/functional/modules/test_x509_v2.py @@ -843,7 +843,7 @@ def test_create_certificate_with_ca_cert_needs_any_pubkey_source(x509, ca_key, c def test_create_certificate_with_extensions(x509, ca_key, ca_cert, rsa_privkey): extensions = { - "basicConstraints": "critical, CA:TRUE, pathlen:1", + "basicConstraints": "critical, CA:TRUE, pathlen:0", "keyUsage": "critical, cRLSign, keyCertSign", "extendedKeyUsage": "OCSPSigning", "subjectKeyIdentifier": "hash", diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index b0cb774ec37..d486ed040df 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -498,7 +498,7 @@ def cert_args(tmp_path, ca_cert_file, ca_key_file): @pytest.fixture def cert_args_exts(): return { - "basicConstraints": "critical, CA:TRUE, pathlen:1", + "basicConstraints": "critical, CA:TRUE, pathlen:0", "keyUsage": "critical, cRLSign, keyCertSign", "extendedKeyUsage": "OCSPSigning", "subjectKeyIdentifier": "hash", @@ -1433,19 +1433,19 @@ def test_pkcs12_friendlyname_change(x509, cert_args, ca_cert, ca_key, rsa_privke @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_extension_added(x509, cert_args, rsa_privkey, ca_key): - cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:1" + cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:0" ret = x509.certificate_managed(**cert_args) cert = _assert_cert_basic(ret, cert_args["name"], rsa_privkey, ca_key) assert "extensions" in ret.changes assert ret.changes["extensions"]["added"] == ["basicConstraints"] assert cert.extensions[0].critical assert cert.extensions[0].value.ca - assert cert.extensions[0].value.path_length + assert cert.extensions[0].value.path_length == 0 @pytest.mark.usefixtures("existing_cert_exts") def test_certificate_managed_extension_changed(x509, cert_args, rsa_privkey, ca_key): - cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:2" + cert_args["basicConstraints"] = "critical, CA:FALSE" cert_args["subjectAltName"] = "DNS:sub.salt.ca,email:subnew@salt.ca" ret = x509.certificate_managed(**cert_args) cert = _assert_cert_basic(ret, cert_args["name"], rsa_privkey, ca_key) @@ -1456,8 +1456,7 @@ def test_certificate_managed_extension_changed(x509, cert_args, rsa_privkey, ca_ } bc = cert.extensions.get_extension_for_class(cx509.BasicConstraints) assert bc.critical - assert bc.value.ca - assert bc.value.path_length == 2 + assert bc.value.ca is False @pytest.mark.usefixtures("existing_cert_exts") @@ -2776,7 +2775,7 @@ def test_certificate_managed_warns_about_long_name_attributes( def test_certificate_managed_warns_about_long_extensions(x509, cert_args, rsa_privkey): - cert_args["X509v3 Basic Constraints"] = "critical CA:TRUE, pathlen:1" + cert_args["X509v3 Basic Constraints"] = "critical CA:TRUE, pathlen:0" cert_args["days_valid"] = 30 cert_args["days_remaining"] = 7 cert_args["private_key"] = rsa_privkey @@ -2788,7 +2787,7 @@ def test_certificate_managed_warns_about_long_extensions(x509, cert_args, rsa_pr assert isinstance(cert.extensions[0].value, cx509.BasicConstraints) assert cert.extensions[0].critical assert cert.extensions[0].value.ca - assert cert.extensions[0].value.path_length == 1 + assert cert.extensions[0].value.path_length == 0 @pytest.mark.parametrize("arg", [{"version": 1}, {"text": True}]) diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 7023d3c1f96..e51fd403ae8 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -18,6 +18,9 @@ cprim = pytest.importorskip( "cryptography.hazmat.primitives", reason="Needs cryptography library" ) +rsa = pytest.importorskip( + "cryptography.hazmat.primitives.asymmetric.rsa", reason="Needs cryptography library" +) @pytest.fixture @@ -131,6 +134,32 @@ def test_split_pems_garbage_between(single_pem): assert len(x.splitlines()) == 27 +@pytest.fixture +def pubkey(): + return """\ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAumZ4+aD8Ez8ZTM2bg1K+ +qN33oigksrzju9VsUS8cz1Hh1g43z9YAfOuLUw8ivGj3c3QNagJT0gonfEwhtvOk +7R89RsQ248qCPY3ItMK73nmWZC27YIarytJrMj/6yZ5QlPrequTNSxDnva/5qhUn +czf96zWG5vb9ow6fqbFQ3KsF0JpyLdCTDvXPH7Ghj0hIVOSxeItPLHjhf1Mpqeqr +n1WExZZvPDvdcMl8ufDwil86eXmKRgb5xfVnnSBdJUyglJr6IOHbtaGbkEM4RRJa +qrvPr5acj4aSWob8VBxqn5cnJ+vs331uQv5oUzQHyLvYaGPeUy0TT62QKhjkG6y1 +4wIDAQAB +-----END PUBLIC KEY----- +""" + + +@pytest.mark.parametrize("get_encoding", (False, True)) +def test_load_pubkey(pubkey, get_encoding): + if get_encoding: + pk, encoding = x509.load_pubkey(pubkey, get_encoding=True) + assert encoding == "pem" + else: + pk = x509.load_pubkey(pubkey) + assert isinstance(pk, rsa.RSAPublicKey) + assert x509.to_pem(pk).decode().strip() == pubkey.strip() + + class TestCreateExtension: @pytest.fixture def aki(self): @@ -143,20 +172,45 @@ def ca_crt(self): return ca @pytest.mark.parametrize( - "val,expected,critical", + "val,expected,critical,self_signed", [ - ("critical,CA:FALSE", (False, None), True), - ("critical, CA:TRUE, pathlen:2", (True, 2), True), - ("CA:TRUE", (True, None), False), - ({"ca": False, "critical": True}, (False, None), True), - ({"ca": True, "pathlen": 3}, (True, 3), False), + ("critical,CA:FALSE", (False, None), True, False), + ("critical, CA:TRUE, pathlen:2", (True, 2), True, True), + ("CA:TRUE", (True, None), False, True), + ({"ca": False, "critical": True}, (False, None), True, False), + ({"ca": True, "pathlen": 3}, (True, 3), False, True), + ( + {"ca": True}, + (True, 0), + False, + False, + ), # default to one less than the issuer, which has 1 + ({"ca": True, "pathlen": 0}, (True, 0), False, False), ], ) - def test_create_basic_constraints(self, val, expected, critical): - with patch("cryptography.x509.BasicConstraints", autospec=True) as ext: - _, crit = x509._create_extension("basicConstraints", val) - assert crit == critical - ext.assert_called_once_with(*expected) + def test_create_basic_constraints( + self, val, expected, critical, self_signed, ca_cert + ): + issuer_cert = x509.load_cert(ca_cert) + exp = cx509.BasicConstraints(*expected) + ext, crit = x509._create_extension( + "basicConstraints", + val, + ca_crt=issuer_cert if not self_signed else None, + ) + assert crit == critical + assert ext == exp + + def test_create_basic_constraints_validates_pathlen(self, ca_cert): + with pytest.raises( + salt.exceptions.CommandExecutionError, + match="less than or equal to requested pathlen", + ): + x509._create_extension( + "basicConstraints", + {"ca": True, "pathlen": 1, "critical": True}, + ca_crt=x509.load_cert(ca_cert), + ) @pytest.mark.parametrize( "val,expected,critical", @@ -563,10 +617,15 @@ def test_create_authority_info_access(self, val, expected): False, ), ( - ["critical", "dns:example.io"], - [cx509.DNSName("example.io")], + ["critical", "dns:*.example.io"], + [cx509.DNSName("*.example.io")], True, ), + ( + [{"ip": "1.2.3.4"}], + [cx509.IPAddress(ipaddress.ip_address("1.2.3.4"))], + False, + ), ( "critical,dns:example.io,email:hello@example.io", [ @@ -575,6 +634,37 @@ def test_create_authority_info_access(self, val, expected): ], True, ), + ( + "ip:2001:db8::1,uri:https://foo.bar.baz,uri:proto://*.foo.bar", + [ + cx509.IPAddress(ipaddress.ip_address("2001:db8::1")), + cx509.UniformResourceIdentifier("https://foo.bar.baz"), + cx509.UniformResourceIdentifier("proto://*.foo.bar"), + ], + False, + ), + ( + [ + "email:user@überexample.com", + "dns:überexample.com", + "dns:*.überexample.com", + "uri:https://überexample.com", + "uri:proto://*.überexample.com", + "otherName:1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:föö@nönasciinäme.example.com", + ], + [ + cx509.RFC822Name("user@xn--berexample-8db.com"), + cx509.DNSName("xn--berexample-8db.com"), + cx509.DNSName("*.xn--berexample-8db.com"), + cx509.UniformResourceIdentifier("https://xn--berexample-8db.com"), + cx509.UniformResourceIdentifier("proto://*.xn--berexample-8db.com"), + cx509.OtherName( + cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), + asn1.encode_der("föö@nönasciinäme.example.com"), + ), + ], + False, + ), ], ) def test_create_subject_alt_name(self, val, expected, critical): @@ -902,13 +992,64 @@ def test_create_inhibit_any_policy(self, val, expected, critical): ( { "critical": True, - "permitted": ["IP:192.168.0.0/255.255.0.0", "email:.example.com"], - "excluded": ["email:.com"], + "excluded": [ + "dns:.no.example.com", + "dns:no.example.com", + "ip:192.168.1.0/24", + "ip:2001:500::/40", + "email:foo@example.io", + "email:foo.example.com", + "email:.foo.example.com", + "uri:no.foo.bar", + "uri:.no.foo.bar", + ], + "permitted": [ + "dns:.example.com", + "dns:example.com", + "dns:überexample.com", + "dns:.überexample.com", + "ip:192.168.0.0/255.255.0.0", + "ip:2001:500::/32", + "email:foo@example.com", + "email:.example.com", + "email:example.io", + "email:foo@überexample.com", + "email:.überexample.com", + "email:überexample.io", + "uri:.foo.bar", + "uri:foo.bar.baz", + "uri:.föö.bar", + "uri:föö.bar.baz", + ], }, - [cx509.RFC822Name(".com")], [ + cx509.DNSName(".no.example.com"), + cx509.DNSName("no.example.com"), + cx509.IPAddress(ipaddress.ip_network("192.168.1.0/24")), + cx509.IPAddress(ipaddress.ip_network("2001:500::/40")), + cx509.RFC822Name("foo@example.io"), + cx509.RFC822Name("foo.example.com"), + cx509.RFC822Name(".foo.example.com"), + cx509.UniformResourceIdentifier("no.foo.bar"), + cx509.UniformResourceIdentifier(".no.foo.bar"), + ], + [ + cx509.DNSName(".example.com"), + cx509.DNSName("example.com"), + cx509.DNSName("xn--berexample-8db.com"), + cx509.DNSName(".xn--berexample-8db.com"), cx509.IPAddress(ipaddress.ip_network("192.168.0.0/16")), + cx509.IPAddress(ipaddress.ip_network("2001:500::/32")), + cx509.RFC822Name("foo@example.com"), cx509.RFC822Name(".example.com"), + cx509.RFC822Name("example.io"), + cx509.RFC822Name("foo@xn--berexample-8db.com"), + cx509.RFC822Name(".xn--berexample-8db.com"), + cx509.RFC822Name("xn--berexample-8db.io"), + cx509.UniformResourceIdentifier(".foo.bar"), + cx509.UniformResourceIdentifier("foo.bar.baz"), + cx509.UniformResourceIdentifier(".xn--f-1gaa.bar"), + cx509.UniformResourceIdentifier("xn--f-1gaa.bar.baz"), ], True, ), @@ -1044,190 +1185,209 @@ def test_create_invalidity_date(self, val, expected, critical): ext.assert_called_once_with(expected) +def _parse_gn_ids(inpt): + if isinstance(inpt, tuple): + return ":".join(str(x) for x in inpt) + if isinstance(inpt, bool): + return "nc" if inpt else "reg" + if isinstance(inpt, type): + return inpt.__name__ + + @pytest.mark.parametrize( - "inpt,cls,parsed", + "inpt,name_constraints,cls,parsed", [ - (("email", "me@example.com"), cx509.RFC822Name, "me@example.com"), - (("email", ".example.com"), cx509.RFC822Name, ".example.com"), + (("DNS", "example.com"), False, cx509.DNSName, "example.com"), + (("DNS", "example.com"), True, cx509.DNSName, "example.com"), ( - ("email", "me@überexample.com"), - cx509.RFC822Name, - "me@xn--berexample-8db.com", + ("DNS", "example.com."), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", ), ( - ("URI", "https://www.example.com"), - cx509.UniformResourceIdentifier, - "https://www.example.com", + ("DNS", "example.com."), + True, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", ), ( - ("URI", "https://www.überexample.com"), + ("DNS", "*.example.com."), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + (("DNS", "example。com"), False, cx509.DNSName, "example.com"), + (("DNS", "example。com"), True, cx509.DNSName, "example.com"), + ( + ("DNS", "example。com。"), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + ( + ("DNS", "example。com。"), + True, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + ( + ("DNS", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + (("DNS", ".example.com"), True, cx509.DNSName, ".example.com"), + ( + ("DNS", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + (("DNS", "。example。com"), True, cx509.DNSName, ".example.com"), + (("DNS", "*.example.com"), False, cx509.DNSName, "*.example.com"), + ( + ("DNS", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + # Also check trailing dots with wilcards, which are only allowed with `URI` + ( + ("URI", "https://*.überexample.com."), + False, cx509.UniformResourceIdentifier, - "https://www.xn--berexample-8db.com", + "https://*.xn--berexample-8db.com.", ), - (("URI", "some/path/only"), cx509.UniformResourceIdentifier, "some/path/only"), - (("DNS", "example.com"), cx509.DNSName, "example.com"), - (("DNS", "überexample.com"), cx509.DNSName, "xn--berexample-8db.com"), - (("DNS", "*.überexample.com"), cx509.DNSName, "*.xn--berexample-8db.com"), - (("DNS", ".überexample.com"), cx509.DNSName, ".xn--berexample-8db.com"), + # The following two are not valid per modern specs, but we're not validating higher-level semantics + (("DNS", "foo.*.example.com"), False, cx509.DNSName, "foo.*.example.com"), ( - ("DNS", "γνῶθι.σεαυτόν.gr"), - cx509.DNSName, - "xn--oxakdo9327a.xn--mxahzvhf4c.gr", + ("DNS", "foo*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", ), - (("RID", "1.2.3.4"), cx509.RegisteredID, cx509.ObjectIdentifier("1.2.3.4")), + (("DNS", "foo*.example.com"), False, cx509.DNSName, "foo*.example.com"), + (("DNS", "überexample.com"), False, cx509.DNSName, "xn--berexample-8db.com"), + (("DNS", "überexample.com"), True, cx509.DNSName, "xn--berexample-8db.com"), ( - ("IP", "13.37.13.37"), - cx509.IPAddress, - ipaddress.ip_address("13.37.13.37"), + ("DNS", ".überexample.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", ), + (("DNS", ".überexample.com"), True, cx509.DNSName, ".xn--berexample-8db.com"), ( - ("IP", "13.37.13.0/24"), - cx509.IPAddress, - ipaddress.ip_network("13.37.13.0/24"), + ("DNS", "*.überexample.com"), + False, + cx509.DNSName, + "*.xn--berexample-8db.com", ), ( - ("IP", "13.37.13.0/255.255.255.0"), - cx509.IPAddress, - ipaddress.ip_network("13.37.13.0/255.255.255.0"), + ("DNS", "*.überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", ), ( - ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), - cx509.IPAddress, - ipaddress.ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + ("DNS", "über*.example.com"), + False, + salt.exceptions.CommandExecutionError, + "Label with wildcard must contain ASCII characters only", ), ( - ("IP", "2001:db8:abcd:0012::0/64"), - cx509.IPAddress, - ipaddress.ip_network("2001:db8:abcd:0012::0/64"), + ("DNS", "γνῶθι.σεαυτόν.gr"), + False, + cx509.DNSName, + "xn--oxakdo9327a.xn--mxahzvhf4c.gr", ), - pytest.param( - ( - "dirName", - "CN=mysite.com,O=My Company,L=San Francisco,ST=California,C=US", - ), - cx509.Name, - [ - cx509.RelativeDistinguishedName( - [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.8"), value="California" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.10"), value="My Company" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" - ) - ] - ), - ], + ( + ("DNS", "γνῶθι.σεαυτόν.gr"), + True, + cx509.DNSName, + "xn--oxakdo9327a.xn--mxahzvhf4c.gr", ), ( - ( - "dirName", - { - "C": "US", - "ST": "California", - "L": "San Francisco", - "O": "My Company", - "CN": "mysite.com", - }, - ), - cx509.Name, - [ - cx509.RelativeDistinguishedName( - [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.8"), value="California" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.10"), value="My Company" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" - ) - ] - ), - ], + ("DNS", "some.invalid_doma.in"), + False, + salt.exceptions.CommandExecutionError, + "at position 8.*not allowed$", ), ( ("DNS", "some.invalid_doma.in"), + True, salt.exceptions.CommandExecutionError, "at position 8.*not allowed$", ), ( ("DNS", "some..invalid-doma.in"), + False, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + ( + ("DNS", "some..invalid-doma.in"), + True, salt.exceptions.CommandExecutionError, "Empty Label", ), ( - ("DNS", "invalid*.wild.card"), + ("DNS", "some.invalid-doma-.in"), + False, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), + ( + ("DNS", "some.invalid-doma-.in"), + True, salt.exceptions.CommandExecutionError, - "at position 8.*not allowed", + "Label must not start or end with a hyphen", ), ( - ("DNS", "invalid.*.wild.card"), + ("DNS", ".*.wildcard-dot.test"), + False, salt.exceptions.CommandExecutionError, - "at position 1.*not allowed", + "Leading dots.*not allowed", ), ( ("DNS", "*..whats.this"), + False, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + ( + ("DNS", 42), + False, salt.exceptions.CommandExecutionError, - "Empty label", + "Expected string value, got int", ), ( ("DNS", 42), - salt.exceptions.SaltInvocationError, + True, + salt.exceptions.CommandExecutionError, "Expected string value, got int", ), ( ("DNS", ""), + False, + salt.exceptions.CommandExecutionError, + "Empty domain", + ), + ( + ("DNS", ""), + True, salt.exceptions.CommandExecutionError, "Empty domain", ), ( ("DNS", "ἀνεῤῥίφθω.κύβος͵.gr"), + False, salt.exceptions.CommandExecutionError, "not allowed at position 6 in 'κύβος͵'$", ), ( ("DNS", "می\u200cخواهم\u200c.iran"), + False, salt.exceptions.CommandExecutionError, # idna < 3.18 says "Joiner U+200C not allowed at position 9"; # idna 3.18+ says "Unknown codepoint adjacent to joiner U+200C @@ -1235,72 +1395,596 @@ def test_create_invalidity_date(self, val, expected, critical): # version range Salt 3006.x ships against. r"U\+200C.*at position 9 in '.*'", ), + # Label length checks. + # DNSName labels must be <64 chars. ( - ("DNS", ".*.wildcard-dot.test"), + ("DNS", 64 * "x" + ".bar.baz"), + False, salt.exceptions.CommandExecutionError, - "Wildcards and leading dots cannot be present together", + "Label too long", ), ( - ("email", "invalid@*.mail.address"), - salt.exceptions.CommandExecutionError, - "Wildcards are not allowed in this context", + ("DNS", 63 * "x" + ".bar.baz"), + False, + cx509.DNSName, + 63 * "x" + ".bar.baz", ), + # U-labels must be encoded to A-labels before the check. ( - ("email", "invalid@.mail.address"), + ("DNS", 62 * "x" + "á" + ".bar.baz"), + False, salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + "Label too long", ), + # Wildcard chars should not count against the limit. ( - ("email", "Invalid Email "), - salt.exceptions.CommandExecutionError, - "not allowed$", + ("DNS", 63 * "x" + "*.bar.baz"), + False, + cx509.DNSName, + 63 * "x" + "*.bar.baz", ), ( - ("IP", "this is not an IP address"), + ("DNS", 64 * "x" + "*.bar.baz"), + False, salt.exceptions.CommandExecutionError, - "does not seem to be an IP address or network range.", + "Label too long", ), + # Domain length checks. + # Domains must be <255 chars (including implicit absolute root marker, i.e. trailing dot). ( - ("URI", "https://*.χάος.σκάλα.gr"), + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + False, salt.exceptions.CommandExecutionError, - "Wildcards are not allowed in this context", + "Domain too long", ), ( - ("URI", "https://.invalid.host"), - salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + False, + cx509.DNSName, + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", ), + # Ensure an explicit trailing dot does not count against limit. Only `URI` allows them ( - ("dirName", "Et tu, Brute?"), - salt.exceptions.CommandExecutionError, - "Failed parsing rfc4514 dirName string", - ), + ( + "URI", + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + False, + cx509.UniformResourceIdentifier, + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + # U-labels must be encoded to A-labels before the check. ( - ("otherName", "1.2.3.4;UTF8:some other identifier"), - cx509.OtherName, ( - cx509.ObjectIdentifier("1.2.3.4"), - asn1.encode_der("some other identifier"), + "DNS", + "á.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", ), + # Wildcard chars should not count against the limit. ( ( - "otherName", - "1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciinäme.example.com", + "DNS", + "*.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", ), - cx509.OtherName, + False, + cx509.DNSName, + "*.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + ( ( - cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), - asn1.encode_der("nonasciinäme.example.com"), + "DNS", + "*.á." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", ), + (("email", "me@example.com"), False, cx509.RFC822Name, "me@example.com"), + (("email", "me@example.com"), True, cx509.RFC822Name, "me@example.com"), ( - ("otherName", "1.2.3.4;BOOL:TRUE"), - salt.exceptions.CommandExecutionError, - ".*only UTF8STRING is supported.*", + ("email", "me@überexample.com"), + False, + cx509.RFC822Name, + "me@xn--berexample-8db.com", + ), + ( + ("email", "me@überexample.com"), + True, + cx509.RFC822Name, + "me@xn--berexample-8db.com", + ), + ( + ("email", "mé@example.com"), + False, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", + ), + ( + ("email", "mé@example.com"), + True, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", + ), + ( + ("email", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Not a valid.*in this context.*", + ), + (("email", ".example.com"), True, cx509.RFC822Name, ".example.com"), + ( + ("email", "example.com"), + False, + salt.exceptions.CommandExecutionError, + "Not a valid.*in this context.*", + ), + ( + ("email", "example.com"), + True, + cx509.RFC822Name, + "example.com", + ), + ( + ("email", "invalid@*.mail.address"), + False, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("email", "invalid@*.mail.address"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("email", "invalid@.mail.address"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots are not allowed in this context", + ), + ( + ("email", "invalid@.mail.address"), + True, + salt.exceptions.CommandExecutionError, + "Leading dots are not allowed in this context", + ), + ( + ("email", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("email", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", "https://www.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com", + ), + ( + ("URI", "https://www.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "*.example.com"), + False, + salt.exceptions.CommandExecutionError, + "URI must contain a scheme", + ), + ( + ("URI", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("URI", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + ( + ("URI", ".example.com"), + True, + cx509.UniformResourceIdentifier, + ".example.com", + ), + ( + ("URI", "https://1.2.3.4"), + False, + cx509.UniformResourceIdentifier, + "https://1.2.3.4", + ), + ( + ("URI", "https://1.2.3.4"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://[2001:db8::1]"), + False, + cx509.UniformResourceIdentifier, + "https://[2001:db8::1]", + ), + ( + ("URI", "https://[2001:db8::1]"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://www.überexample.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.xn--berexample-8db.com", + ), + ( + ("URI", "https://www.überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "proto://*.example.com"), + False, + cx509.UniformResourceIdentifier, + "proto://*.example.com", + ), + ( + ("URI", "proto://*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "proto://.example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + ( + ("URI", "proto://.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "mailto:foo@example.com"), + False, + cx509.UniformResourceIdentifier, + "mailto:foo@example.com", + ), + ( + ("URI", "mailto:foo@example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "mailto:föö@überexample.com"), + False, + cx509.UniformResourceIdentifier, + "mailto:f%C3%B6%C3%B6@%C3%BCberexample.com", # not ideal, but we can't account for everything + ), + ( + ("URI", "https://user@example.com:1337"), + False, + cx509.UniformResourceIdentifier, + "https://user@example.com:1337", + ), + ( + ("URI", "https://user@example.com:0x1337"), + False, + salt.exceptions.CommandExecutionError, + "Port could not be cast to integer value", + ), + ( + ("URI", "https://user:pass@χάος.σκάλα.gr:1337"), + False, + cx509.UniformResourceIdentifier, + "https://user:pass@xn--hxa2bjr.xn--hxakzf1b.gr:1337", + ), + ( + ( + "URI", + "https://user:pass@example.com:1337/path/segment?query_param=foo#fragment", + ), + False, + cx509.UniformResourceIdentifier, + "https://user:pass@example.com:1337/path/segment?query_param=foo#fragment", + ), + ( + ( + "URI", + "https://üsér:paß$WORD@example.com:1337/päth/ségment?quéry_päram=föö&othér=bär#frägment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@example.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ( + "URI", + "https://üsér:paß$WORD@überexample.com:1337/päth/ségment?quéry_päram=föö&othér=bär#frägment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ( + "URI", + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ("URI", "https://example.com/%foobar"), + False, + salt.exceptions.CommandExecutionError, + "Invalid percent-encoding", + ), + ( + ("URI", "some/path/only"), + False, + salt.exceptions.CommandExecutionError, + "URI must contain a scheme", + ), + ( + ("URI", "some/path/only"), + True, + salt.exceptions.CommandExecutionError, + r"Codepoint U\+002F.*5 of 'some/path/only' not allowed", + ), + ( + ("URI", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("RID", "1.2.3.4"), + False, + cx509.RegisteredID, + cx509.ObjectIdentifier("1.2.3.4"), + ), + ( + ("IP", "13.37.13.37"), + False, + cx509.IPAddress, + ipaddress.ip_address("13.37.13.37"), + ), + ( + ("IP", "13.37.13.37"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.37/32"), + ), + ( + ("IP", "13.37.13.0/24"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", "13.37.13.0/24"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.0/24"), + ), + ( + ("IP", "13.37.13.0/255.255.255.0"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.0/255.255.255.0"), + ), + ( + ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + False, + cx509.IPAddress, + ipaddress.ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + ), + ( + ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + True, + cx509.IPAddress, + ipaddress.ip_network("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"), + ), + ( + ("IP", "2001:db8:abcd:0012::0/64"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", "2001:db8:abcd:0012::0/64"), + True, + cx509.IPAddress, + ipaddress.ip_network("2001:db8:abcd:0012::0/64"), + ), + ( + ("IP", "this is not an IP address"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", ("hi", "there")), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", ("hi", "there")), + True, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 network range", + ), + pytest.param( + ( + "dirName", + "CN=mysite.com,O=My Company,L=San Francisco,ST=California,C=US", + ), + False, + cx509.Name, + [ + cx509.RelativeDistinguishedName( + [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.8"), value="California" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.10"), value="My Company" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" + ) + ] + ), + ], + ), + ( + ( + "dirName", + { + "C": "US", + "ST": "California", + "L": "San Francisco", + "O": "My Company", + "CN": "mysite.com", + }, + ), + False, + cx509.Name, + [ + cx509.RelativeDistinguishedName( + [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.8"), value="California" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.10"), value="My Company" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" + ) + ] + ), + ], + ), + ( + ("dirName", "Et tu, Brute?"), + False, + salt.exceptions.CommandExecutionError, + "Failed parsing rfc4514 dirName string", + ), + ( + ("otherName", "1.2.3.4;UTF8:some other identifier"), + False, + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("some other identifier"), + ), + ), + ( + ( + "otherName", + "1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciinäme.example.com", + ), + False, + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), + asn1.encode_der("nonasciinäme.example.com"), + ), + ), + ( + ("otherName", "1.2.3.4;BOOL:TRUE"), + False, + salt.exceptions.CommandExecutionError, + ".*only UTF8STRING is supported.*", ), ( ("otherName", {"oid": "1.2.3.4", "value": "some other identifier"}), + False, cx509.OtherName, ( cx509.ObjectIdentifier("1.2.3.4"), @@ -1309,11 +1993,13 @@ def test_create_invalidity_date(self, val, expected, critical): ), ( ("otherName", {"oid": "1.2.3.4", "value": True}), + False, cx509.OtherName, (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(True)), ), ( ("otherName", {"oid": "1.2.3.4", "value": None}), + False, cx509.OtherName, (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(asn1.Null())), ), @@ -1325,6 +2011,7 @@ def test_create_invalidity_date(self, val, expected, critical): "der": "hex:" + asn1.encode_der("hex encoded utf8string").hex(), }, ), + False, cx509.OtherName, ( cx509.ObjectIdentifier("1.2.3.4"), @@ -1344,6 +2031,7 @@ def test_create_invalidity_date(self, val, expected, critical): ).decode(), }, ), + False, cx509.OtherName, ( cx509.ObjectIdentifier("1.2.3.4"), @@ -1354,41 +2042,47 @@ def test_create_invalidity_date(self, val, expected, critical): ), ( ("otherName", []), + False, salt.exceptions.CommandExecutionError, ".*dict or string required.*", ), ( ("otherName", {}), + False, salt.exceptions.CommandExecutionError, ".*missing `oid` key.*", ), ( ("otherName", {"oid": "1.2.3.4"}), + False, salt.exceptions.CommandExecutionError, ".*missing `value` or `der` key.*", ), ( ("otherName", {"oid": "1.2.3.4", "der": "foobar"}), + False, salt.exceptions.CommandExecutionError, ".*needs `hex:` or `b64:` prefix.*", ), ( ("invalidType", "L'état c'est moi!"), + False, salt.exceptions.CommandExecutionError, "GeneralName type invalidtype is invalid", ), ], + ids=_parse_gn_ids, ) -def test_parse_general_names(inpt, cls, parsed): +def test_parse_general_names(inpt, name_constraints, cls, parsed): if issubclass(cls, Exception): with pytest.raises(cls, match=parsed): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt], name_constraints=name_constraints) return if inpt[0] == "otherName": expected = cls(*parsed) else: expected = cls(parsed) - res = x509._parse_general_names([inpt]) + res = x509.parse_general_names([inpt], name_constraints=name_constraints) if inpt[0] == "dirName": assert res[0].value == expected else: @@ -1396,92 +2090,291 @@ def test_parse_general_names(inpt, cls, parsed): @pytest.mark.parametrize( - "inpt,cls,parsed", + "inpt,name_constraints,cls,parsed", [ - (("email", "me@example.com"), cx509.RFC822Name, "me@example.com"), + (("DNS", "example.com"), False, cx509.DNSName, "example.com"), + (("DNS", "example.com"), True, cx509.DNSName, "example.com"), + (("DNS", "*.example.com"), False, cx509.DNSName, "*.example.com"), ( - ("URI", "https://www.example.com"), - cx509.UniformResourceIdentifier, - "https://www.example.com", + ("DNS", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards are not allowed", ), - (("DNS", "example.com"), cx509.DNSName, "example.com"), - (("DNS", "*.example.com"), cx509.DNSName, "*.example.com"), - (("DNS", ".example.com"), cx509.DNSName, ".example.com"), ( - ("DNS", "invalid*.wild.card"), + ("DNS", ".example.com"), + False, salt.exceptions.CommandExecutionError, - "at position 8.*not allowed", + "Leading dots.*not allowed", + ), + (("DNS", ".example.com"), True, cx509.DNSName, ".example.com"), + ( + ("DNS", "some*.wild.card"), + False, + cx509.DNSName, + "some*.wild.card", ), ( - ("DNS", "invalid.*.wild.card"), + ("DNS", "some*.wild.card"), + True, salt.exceptions.CommandExecutionError, - "at position 1.*not allowed", + "Wildcards are not allowed", + ), + ( + ("DNS", "some.*.wild.card"), + False, + cx509.DNSName, + "some.*.wild.card", ), ( ("DNS", ".*.wildcard-dot.test"), + False, salt.exceptions.CommandExecutionError, - "Wildcards and leading dots cannot be present together", + "Leading dots.*not allowed", ), ( ("DNS", "gott.würfelt.nicht"), + False, salt.exceptions.CommandExecutionError, "Cannot encode non-ASCII strings", ), ( ("DNS", "some.invalid_doma.in"), + False, salt.exceptions.CommandExecutionError, "at position 8.*not allowed$", ), + ( + ("DNS", "some.invalid_doma.in"), + True, + salt.exceptions.CommandExecutionError, + "at position 8.*not allowed$", + ), + ( + ("DNS", "some-.invalid-doma.in"), + False, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), + ( + ("DNS", "some-.invalid-doma.in"), + True, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), ( ("DNS", "some..invalid-doma.in"), + False, salt.exceptions.CommandExecutionError, "Empty Label", ), ( ("DNS", 42), - salt.exceptions.SaltInvocationError, + False, + salt.exceptions.CommandExecutionError, "Expected string value, got int", ), ( ("DNS", ""), + False, salt.exceptions.CommandExecutionError, "Empty domain", ), ( ("DNS", "*..whats.this"), + False, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + # Label length checks. + # Don't need to check wildcard variants, handling of them does not differ when missing idna. + # Can't check U-label variants because we're simulating missing idna. + # DNSName labels must be <64 chars. + ( + ("DNS", 64 * "x" + ".bar.baz"), + False, + salt.exceptions.CommandExecutionError, + "Label too long", + ), + ( + ("DNS", 63 * "x" + ".bar.baz"), + False, + cx509.DNSName, + 63 * "x" + ".bar.baz", + ), + # Domain length checks. + # Domains must be <255 chars (including implicit absolute root marker, i.e. trailing dot). + ( + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", + ), + ( + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + False, + cx509.DNSName, + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + # Ensure an explicit trailing dot does not count against limit. Only `URI` allows them + ( + ( + "URI", + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + False, + cx509.UniformResourceIdentifier, + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + (("email", "me@example.com"), False, cx509.RFC822Name, "me@example.com"), + (("email", "me@example.com"), True, cx509.RFC822Name, "me@example.com"), + ( + ("email", "me@überexample.com"), + False, salt.exceptions.CommandExecutionError, - "Empty label", + "missing library: idna", + ), + ( + ("email", "me@überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "missing library: idna", + ), + ( + ("email", "mé@example.com"), + False, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", + ), + ( + ("email", "mé@example.com"), + True, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", ), ( ("email", "invalid@*.mail.address"), + False, salt.exceptions.CommandExecutionError, "Wildcards are not allowed in this context", ), ( ("email", "invalid@.mail.address"), + False, salt.exceptions.CommandExecutionError, "Leading dots are not allowed in this context", ), ( ("email", "Invalid Email "), + False, salt.exceptions.CommandExecutionError, "not allowed$", ), + ( + ("email", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("email", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", "https://www.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com", + ), + ( + ("URI", "https://www.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://1.2.3.4"), + False, + cx509.UniformResourceIdentifier, + "https://1.2.3.4", + ), + ( + ("URI", "https://[2001:db8::1]"), + False, + cx509.UniformResourceIdentifier, + "https://[2001:db8::1]", + ), + ( + ("URI", "https://www.überexample.com"), + False, + salt.exceptions.CommandExecutionError, + "missing library: idna", + ), + ( + ("URI", "https://www.example.com/päth/ségment"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com/p%C3%A4th/s%C3%A9gment", + ), ( ("URI", "https://.invalid.host"), + False, salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + "Leading dots.*not allowed", + ), + ( + ("URI", "https://*.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://*.example.com", + ), + ( + ("URI", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", ), ], + ids=_parse_gn_ids, ) -def test_parse_general_names_without_idna(inpt, cls, parsed): +def test_parse_general_names_without_idna(inpt, name_constraints, cls, parsed): with patch("salt.utils.x509.HAS_IDNA", False): if issubclass(cls, Exception): with pytest.raises(cls, match=parsed): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt], name_constraints=name_constraints) return expected = cls(parsed) - res = x509._parse_general_names([inpt]) + res = x509.parse_general_names([inpt], name_constraints=name_constraints) if inpt[0] == "dirName": assert res[0].value == expected else: @@ -1503,7 +2396,7 @@ def test_parse_general_names_without_idna(inpt, cls, parsed): ) def test_parse_general_names_rejects_invalid(inpt): with pytest.raises(salt.exceptions.CommandExecutionError): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt]) @pytest.mark.parametrize(