diff --git a/salt/client/ssh/wrapper/x509_v2.py b/salt/client/ssh/wrapper/x509_v2.py new file mode 100644 index 000000000000..ccdbde979636 --- /dev/null +++ b/salt/client/ssh/wrapper/x509_v2.py @@ -0,0 +1,1004 @@ +""" +Manage X.509 certificates +========================= + +.. versionadded:: 3007.0 + +General configuration instructions and general remarks are documented +in the :ref:`execution module docs `. + +Configuration +------------- +Explicit activation +~~~~~~~~~~~~~~~~~~~ +Since this module uses the same virtualname as the previous ``x509`` modules, +but is incompatible with them, it needs to be explicitly activated on each +SSH minion **and the master itself** (the latter one is a technical limitation/ +bordering a bug: The wrapper modules are loaded with the master opts the first time +and only those that were registered successfully will be reloaded with the +merged opts after). + +.. code-block:: yaml + + # /etc/salt/master.d/x509.conf + + features: + x509_v2: true + ssh_minion_opts: + features: + x509_v2: true + +.. note:: + + Compound matching allowed callers is **not supported** with salt-ssh + minions. They will always be denied. +""" +import copy +import logging +from pathlib import Path + +try: + import salt.utils.x509 as x509util + + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +import salt.utils.dictupdate +import salt.utils.files +import salt.utils.stringutils +from salt.exceptions import CommandExecutionError, SaltInvocationError + +log = logging.getLogger(__name__) + + +__virtualname__ = "x509" + + +def __virtual__(): + if not HAS_CRYPTOGRAPHY: + return (False, "Could not load cryptography") + # salt.features appears to not be setup when invoked via peer publishing + if not __opts__.get("features", {}).get("x509_v2"): + return ( + False, + "x509_v2 needs to be explicitly enabled by setting `x509_v2: true` " + "in the minion configuration value `features` until Salt 3008 (Argon).", + ) + return __virtualname__ + + +def create_certificate( + ca_server=None, + signing_policy=None, + encoding="pem", + append_certs=None, + pkcs12_passphrase=None, + pkcs12_encryption_compat=False, + pkcs12_friendlyname=None, + path=None, + overwrite=True, + raw=False, + **kwargs, +): + """ + Create an X.509 certificate and return an encoded version of it. + + .. note:: + + All parameters that take a public key, private key or certificate + can be specified either as a PEM/hex/base64 string or a path to a + local file encoded in all supported formats for the type. + + CLI Example: + + .. code-block:: bash + + salt '*' x509.create_certificate signing_private_key='/etc/pki/myca.key' csr='/etc/pki/my.csr' + + ca_server + Request a remotely signed certificate from ca_server. For this to + work, a ``signing_policy`` must be specified, and that same policy + must be configured on the ca_server. See `Signing policies`_ for + details. Also, the Salt master must permit peers to call the + ``sign_remote_certificate`` function, see `Peer communication`_. + + signing_policy + The name of a configured signing policy. Parameters specified in there + are hardcoded and cannot be overridden. This is required for remote signing, + otherwise optional. See `Signing policies`_ for details. + + encoding + Specify the encoding of the resulting certificate. It can be returned + as a ``pem`` (or ``pkcs7_pem``) string or several (base64-encoded) + binary formats (``der``, ``pkcs7_der``, ``pkcs12``). Defaults to ``pem``. + + append_certs + A list of additional certificates to append to the new one, e.g. to create a CA chain. + + .. note:: + + Mind that when ``der`` encoding is in use, appending certificatees is prohibited. + + copypath + Create a copy of the issued certificate in PEM format in this directory. + The file will be named ``.crt`` if prepend_cn is False. + + prepend_cn + When ``copypath`` is set, prepend the common name of the certificate to + the file name like so: ``-.crt``. Defaults to false. + + pkcs12_passphrase + When encoding a certificate as ``pkcs12``, encrypt it with this passphrase. + + .. note:: + + PKCS12 encryption is very weak and `should not be relied on for security `_. + + pkcs12_encryption_compat + OpenSSL 3 and cryptography v37 switched to a much more secure default + encryption for PKCS12, which might be incompatible with some systems. + This forces the legacy encryption. Defaults to False. + + pkcs12_friendlyname + When encoding a certificate as ``pkcs12``, a name for the certificate can be included. + + path + Instead of returning the certificate, write it to this file path. + + overwrite + If ``path`` is specified and the file exists, overwrite it. + Defaults to true. + + raw + Return the encoded raw bytes instead of a string. Defaults to false. + + digest + The hashing algorithm to use for the signature. Valid values are: + sha1, sha224, sha256, sha384, sha512, sha512_224, sha512_256, sha3_224, + sha3_256, sha3_384, sha3_512. Defaults to ``sha256``. + This will be ignored for ``ed25519`` and ``ed448`` key types. + + private_key + The private key corresponding to the public key the certificate should + be issued for. This is one way of specifying the public key that will + be included in the certificate, the other ones being ``public_key`` and ``csr``. + + private_key_passphrase + If ``private_key`` is specified and encrypted, the passphrase to decrypt it. + + public_key + The public key the certificate should be issued for. Other ways of passing + the required information are ``private_key`` and ``csr``. If neither are set, + the public key of the ``signing_private_key`` will be included, i.e. + a self-signed certificate is generated. + + csr + A certificate signing request to use as a base for generating the certificate. + The following information will be respected, depending on configuration: + * public key + * extensions, if not otherwise specified (arguments, signing_policy) + + signing_cert + The CA certificate to be used for signing the issued certificate. + + signing_private_key + The private key corresponding to the public key in ``signing_cert``. Required. + + signing_private_key_passphrase + If ``signing_private_key`` is encrypted, the passphrase to decrypt it. + + serial_number + A serial number to be embedded in the certificate. If unspecified, will + autogenerate one. This should be an integer, either in decimal or + hexadecimal notation. + + not_before + Set a specific date the certificate should not be valid before. + The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. + Defaults to the time of issuance. + + not_after + Set a specific date the certificate should not be valid after. + The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. + If unspecified, defaults to the current time plus ``days_valid`` days. + + days_valid + If ``not_after`` is unspecified, the number of days from the time of issuance + the certificate should be valid for. Defaults to ``30``. + + subject + The subject's distinguished name embedded in the certificate. This is one way of + passing this information (see ``kwargs`` below for the other). + This argument will be preferred and allows to control the order of RDNs in the DN + as well as to embed RDNs with multiple attributes. + This can be specified as an RFC4514-encoded string (``CN=example.com,O=Example Inc,C=US``, + mind that the rendered order is reversed from what is embedded), a list + of RDNs encoded as in RFC4514 (``["C=US", "O=Example Inc", "CN=example.com"]``) + or a dictionary (``{"CN": "example.com", "C": "US", "O": "Example Inc"}``, + default ordering). + Multiple name attributes per RDN are concatenated with a ``+``. + + .. note:: + + Parsing of RFC4514 strings requires at least cryptography release 37. + + kwargs + Embedded X.509v3 extensions and the subject's distinguished name can be + controlled via supplemental keyword arguments. See the following for an overview. + + Subject properties in kwargs + C, ST, L, STREET, O, OU, CN, MAIL, SN, GN, UID, SERIALNUMBER + + X.509v3 extensions in kwargs + Most extensions can be configured using the same string format as OpenSSL, + while some require adjustments. In general, since the strings are + parsed to dicts/lists, you can always use the latter formats directly. + Marking an extension as critical is done by including it at the beginning + of the configuration string, in the list or as a key in the dictionary + with the value ``true``. + + Examples (some showcase dict/list correspondance): + + basicConstraints + ``critical, CA:TRUE, pathlen:1`` or + + .. code-block:: yaml + + - basicConstraints: + critical: true + ca: true + pathlen: 1 + + keyUsage + ``critical, cRLSign, keyCertSign`` or + + .. code-block:: yaml + + - keyUsage: + - critical + - cRLSign + - keyCertSign + + subjectKeyIdentifier + This can be an explicit value or ``hash``, in which case the value + will be set to the SHA1 hash of some encoding of the associated public key, + depending on the underlying algorithm (RSA/ECDSA/EdDSA). + + authorityKeyIdentifier + ``keyid:always, issuer`` + + subjectAltName + There is support for all OpenSSL-defined types except ``otherName``. + + ``email:me@example.com,DNS:example.com`` or + + .. code-block:: yaml + + # mind this being a list, not a dict + - subjectAltName: + - email:me@example.com + - DNS:example.com + + issuerAltName + The syntax is the same as for ``subjectAltName``, except that the additional + value ``issuer:copy`` is supported, which will copy the values of + ``subjectAltName`` in the issuer's certificate. + + authorityInfoAccess + ``OCSP;URI:http://ocsp.example.com/,caIssuers;URI:http://myca.example.com/ca.cer`` + + crlDistributionPoints + When set to a string value, items are interpreted as fullnames: + + ``URI:http://example.com/myca.crl, URI:http://example.org/my.crl`` + + There is also support for more attributes using the full form: + + .. code-block:: yaml + + - crlDistributionPoints: + - fullname: URI:http://example.com/myca.crl + crlissuer: DNS:example.org + reasons: + - keyCompromise + - URI:http://example.org/my.crl + + certificatePolicies + ``critical, 1.2.4.5, 1.1.3.4`` + + Again, there is support for more attributes using the full form: + + .. code-block:: yaml + + - certificatePolicies: + critical: true + 1.2.3.4.5: https://my.ca.com/pratice_statement + 1.2.4.5.6: + - https://my.ca.com/pratice_statement + - organization: myorg + noticeNumbers: [1, 2, 3] + text: mytext + + policyConstraints + ``requireExplicitPolicy:3,inhibitPolicyMapping:1`` + + inhibitAnyPolicy + The value is just an integer: ``- inhibitAnyPolicy: 1`` + + nameConstraints + ``critical,permitted;IP:192.168.0.0/255.255.0.0,permitted;email:.example.com,excluded;email:.com`` + + .. code-block:: yaml + + - nameConstraints: + critical: true + permitted: + - IP:192.168.0.0/24 + - email:.example.com + excluded: + - email:.com + noCheck + This extension does not take any values, except ``critical``. Just the presence + in the keyword args will include it. + + tlsfeature + ``status_request`` + + For more information, visit the `OpenSSL docs `_. + """ + if raw: + # returns are json-serialized, which does not support bytes + raise SaltInvocationError("salt-ssh does not support the `raw` parameter") + + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_")} + + if not ca_server: + return _check_ret( + __salt__["x509.create_certificate_ssh"]( + signing_policy=signing_policy, + encoding=encoding, + append_certs=append_certs, + pkcs12_passphrase=pkcs12_passphrase, + pkcs12_encryption_compat=pkcs12_encryption_compat, + pkcs12_friendlyname=pkcs12_friendlyname, + path=path, + overwrite=overwrite, + raw=raw, + **kwargs, + ) + ) + + # Deprecation checks vs the old x509 module + if "algorithm" in kwargs: + salt.utils.versions.warn_until( + 3009, + "`algorithm` has been renamed to `digest`. Please update your code.", + ) + kwargs["digest"] = kwargs.pop("algorithm") + + ignored_params = {"text", "version", "serial_bits"}.intersection( + kwargs + ) # path, overwrite + if ignored_params: + salt.utils.versions.kwargs_warn_until(ignored_params, "Potassium") + kwargs = x509util.ensure_cert_kwargs_compat(kwargs) + + if "days_valid" not in kwargs and "not_after" not in kwargs: + try: + salt.utils.versions.warn_until( + 3009, + "The default value for `days_valid` will change to 30. Please adapt your code accordingly.", + ) + kwargs["days_valid"] = 365 + except RuntimeError: + pass + + if encoding not in ["der", "pem", "pkcs7_der", "pkcs7_pem", "pkcs12"]: + raise CommandExecutionError( + f"Invalid value '{encoding}' for encoding. Valid: " + "der, pem, pkcs7_der, pkcs7_pem, pkcs12" + ) + if kwargs.get("digest", "sha256").lower() not in [ + "sha1", + "sha224", + "sha256", + "sha384", + "sha512", + "sha512_224", + "sha512_256", + "sha3_224", + "sha3_256", + "sha3_384", + "sha3_512", + ]: + raise CommandExecutionError( + f"Invalid value '{kwargs['digest']}' for digest. Valid: sha1, sha224, " + "sha256, sha384, sha512, sha512_224, sha512_256, sha3_224, sha3_256, " + "sha3_384, sha3_512" + ) + if encoding == "der" and append_certs: + raise SaltInvocationError("Cannot encode a certificate chain in DER") + if encoding == "pkcs12" and "private_key" not in kwargs: + # The creation will work, but it will be listed in additional certs, not + # as the main certificate. This might confuse other parts of the code. + raise SaltInvocationError( + "Creating a PKCS12-encoded certificate without embedded private key " + "is unsupported" + ) + + if path and not overwrite and _check_ret(__salt__["file.file_exists"](path)): + return f"The file at {path} exists and overwrite was set to false" + if signing_policy is None: + raise SaltInvocationError( + "signing_policy must be specified to request a certificate from " + "a remote ca_server" + ) + cert, private_key_loaded = _create_certificate_remote( + ca_server, signing_policy, **kwargs + ) + + if encoding == "pkcs12": + out = _check_ret( + __salt__["x509.encode_certificate"]( + x509util.to_pem(cert).decode(), + append_certs=append_certs, + encoding=encoding, + private_key=private_key_loaded, + pkcs12_passphrase=pkcs12_passphrase, + pkcs12_encryption_compat=pkcs12_encryption_compat, + pkcs12_friendlyname=pkcs12_friendlyname, + raw=False, + ) + ) + else: + out = _check_ret( + __salt__["x509.encode_certificate"]( + x509util.to_pem(cert).decode(), + append_certs=append_certs, + encoding=encoding, + raw=False, + ) + ) + + if path is None: + return out + + if encoding == "pem": + return _check_ret( + __salt__["x509.write_pem"]( + out, path, overwrite=overwrite, pem_type="CERTIFICATE" + ) + ) + _check_ret(__salt__["hashutil.base64_decodefile"](out, path)) + return f"Certificate written to {path}" + + +def _query_remote(ca_server, signing_policy, kwargs, get_signing_policy_only=False): + result = __salt__["publish.publish"]( + ca_server, + "x509.sign_remote_certificate", + arg=[signing_policy, kwargs, get_signing_policy_only], + regular_minions=True, + ) + + if not result: + raise SaltInvocationError( + "ca_server did not respond." + " Salt master must permit peers to" + " call the sign_remote_certificate function." + ) + result = result[next(iter(result))] + if not isinstance(result, dict) or "data" not in result: + log.error(f"Received invalid return value from ca_server: {result}") + raise CommandExecutionError( + "Received invalid return value from ca_server. See minion log for details" + ) + if result.get("errors"): + raise CommandExecutionError( + "ca_server reported errors:\n" + "\n".join(result["errors"]) + ) + return result["data"] + + +def _create_certificate_remote( + ca_server, signing_policy, private_key=None, private_key_passphrase=None, **kwargs +): + private_key_loaded = None + if private_key: + kwargs["public_key"] = _check_ret( + __salt__["x509.get_public_key"]( + private_key, passphrase=private_key_passphrase + ) + ) + elif kwargs.get("public_key"): + kwargs["public_key"] = _check_ret( + __salt__["x509.get_public_key"](kwargs["public_key"]) + ) + + if kwargs.get("csr"): + try: + # Check if the data can be interpreted as a Path at all + Path(kwargs["csr"]) + except TypeError: + pass + else: + if _check_ret(__salt__["file.file_exists"](kwargs["csr"])): + kwargs["csr"] = _check_ret( + __salt__["hashutil.base64_encodefile"](kwargs["csr"]) + ) + + result = _query_remote(ca_server, signing_policy, kwargs) + try: + return x509util.load_cert(result), private_key_loaded + except (CommandExecutionError, SaltInvocationError) as err: + raise CommandExecutionError( + f"ca_server did not return a certificate: {result}" + ) from err + + +def get_signing_policy(signing_policy, ca_server=None): + """ + Returns the specified named signing policy. + + CLI Example: + + .. code-block:: bash + + salt '*' x509.get_signing_policy www + + signing_policy + The name of the signing policy to return. + + ca_server + If this is set, the CA server will be queried for the + signing policy instead of looking it up locally. + """ + if ca_server is None: + policy = _get_signing_policy(signing_policy) + else: + # Cache signing policies from remote during this run + # to reduce unnecessary resource usage. + ckey = "_x509_policies" + if ckey not in __context__: + __context__[ckey] = {} + if ca_server not in __context__[ckey]: + __context__[ckey][ca_server] = {} + if signing_policy not in __context__[ckey][ca_server]: + policy_ = _query_remote( + ca_server, signing_policy, {}, get_signing_policy_only=True + ) + if "signing_cert" in policy_: + policy_["signing_cert"] = x509util.to_pem( + x509util.load_cert(policy_["signing_cert"]) + ).decode() + __context__[ckey][ca_server][signing_policy] = policy_ + # only hand out copies of the cached policy + policy = copy.deepcopy(__context__[ckey][ca_server][signing_policy]) + + # Don't immediately break for the long form of name attributes + for name, long_names in x509util.NAME_ATTRS_ALT_NAMES.items(): + for long_name in long_names: + if long_name in policy: + salt.utils.versions.warn_until( + 3009, + f"Found {long_name} in {signing_policy}. Please migrate to the short name: {name}", + ) + policy[name] = policy.pop(long_name) + + # Don't immediately break for the long form of extensions + for extname, long_names in x509util.EXTENSIONS_ALT_NAMES.items(): + for long_name in long_names: + if long_name in policy: + salt.utils.versions.warn_until( + 3009, + f"Found {long_name} in {signing_policy}. Please migrate to the short name: {extname}", + ) + policy[extname] = policy.pop(long_name) + return policy + + +def _get_signing_policy(name): + if name is None: + return {} + policies = __salt__["pillar.get"]("x509_signing_policies", {}).get(name) + policies = policies or __salt__["config.get"]("x509_signing_policies", {}).get(name) + if isinstance(policies, list): + dict_ = {} + for item in policies: + dict_.update(item) + policies = dict_ + return policies or {} + + +def _check_ret(ret): + # Failing unwrapped calls to the minion always return a result dict + # and do not throw exceptions currently. + if isinstance(ret, dict) and ret.get("stderr"): + raise CommandExecutionError(ret["stderr"]) + return ret + + +def certificate_managed_wrapper( + name, + ca_server, + signing_policy, + private_key_managed=None, + private_key=None, + private_key_passphrase=None, + csr=None, + public_key=None, + certificate_managed=None, +): + """ + This function essentially behaves like a sophisticated Jinja macro. + It is intended to provide a replacement for the ``x509.certificate_managed`` + state with peer publishing, which does not work via salt-ssh. + It performs necessary checks during rendering and returns an appropriate + highstate structure that does work via salt-ssh (if a certificate needs to be + reissued, it is done during rendering and the actual state just manages the file). + + Required arguments are ``name``, ``ca_server`` and ``signing_policy``. + If you want this function to manage a private key, it should be specified + in ``private_key_managed``, which should contain all arguments to the + respective state. Note that the private key will not be checked for changes. + If you want to use a CSR or a public key as a source, + it must exist during state rendering and you cannot manage a private key. + + All optional keyword arguments to ``certificate_managed`` can be specified + in the dict param ``certificate_managed``. + Key rotation can be activated by including ``new: true`` in the dict for + ``private_key_managed``. + + As an example, for Jinja templates, you can serialize this function's output + directly into the state file: + + .. code-block:: jinja + + {%- set private_key_params = { + "name": "/opt/app/certs/app.key", + "algo": "ed25519", + "new": true + } %} + {%- set certificate_params = { + "basicConstraints": "critical, CA:false", + "subjectKeyIdentifier": "hash", + "authorityKeyIdentifier": "keyid:always", + "subjectAlternativeNamems": ["DNS:my.minion.example.com"], + "CN": "DNS:my.minion.example.com", + "days_remaining": 7, + "days_valid": 30 + } %} + {{ + salt["x509.certificate_managed_wrapper"]( + "/opt/app/certs/app.crt", + ca_server="ca_minion", + signing_policy="www", + private_key_managed=private_key_params, + certificate_managed=certificate_params + ) | yaml(false) + }} + + + name + The path of the certificate to manage. + + ca_server + The CA server to contact. This is required since this function + is not necessary for locally signed certificates. + + signing_policy + The name of the signing policy to use. Required since remotely + signing a certificate requires a policy. + + private_key_managed + A dictionary of keyword arguments to ``x509.private_key_managed``. + This is required if ``private_key``, ``csr`` or ``public_key`` + have not been specified. + Key rotation will be performed automatically if ``new: true``. + + private_key + The path of a private key to use for public key derivation + (it will not be managed). + Does not accept the key itself. Mutually exclusive with ``private_key_managed``, + ``csr`` and ``public_key``. + + private_key_passphrase + If the specified private key needs a passphrase, specify it herre. + + csr + The path of a CSR to use for public key derivation. + Does not accept the CSR itself. Mutually exclusive with ``private_key_managed``, + ``private_key`` and ``public_key``. + + public_key + The path of a public key to use. + Does not accept the CSR itself. Mutually exclusive with ``private_key_managed``, + ``private_key`` and ``public_key``. + + certificate_managed + A dictionary of keyword arguments to ``x509.certificate_managed``. + + .. note:: + + This function does not claim feature parity, but it uses the same + change check as the regular state module. Special handling for symlinks + and other edge cases is not implemented. + + There will be one or two resulting states, depending on the presence of + ``private_key_managed``. Both states will have the managed file path as + their state ID (suffixed with either _key or _crt), the state module + will always be ``x509``. + + Private keys will not leave the remote machine, unless you're managing + PKCS12 certificates. + """ + if not (private_key_managed or private_key or csr or public_key): + raise SaltInvocationError( + "Need to specify either private_key_managed, private_key, csr or public_key" + ) + + create_private_key = False + recreate_private_key = False + new_certificate = False + reencode_certificate = False + certificate_managed = certificate_managed or {} + private_key_managed = private_key_managed or {} + public_key = None + cm_defaults = { + "days_remaining": 7, + "days_valid": 30, + "not_before": None, + "not_after": None, + "encoding": "pem", + "append_certs": [], + "digest": "sha256", + } + for param, val in cm_defaults.items(): + certificate_managed.setdefault(param, val) + + cert_file_args, cert_args = x509util.split_file_kwargs(certificate_managed) + pk_file_args, pk_args = x509util.split_file_kwargs(private_key_managed) + ret = {} + current = None + cert_changes = {} + pk_temp_file = None + + try: + # Check if we have a source for a public key + if pk_args: + private_key = pk_args["name"] + if not _check_ret(__salt__["file.file_exists"](private_key)): + create_private_key = True + else: + public_key = _check_ret( + __salt__["x509.get_public_key"]( + pk_args["name"], + pk_args.get("passphrase"), + ) + ) + elif private_key: + if not _check_ret(__salt__["file.file_exists"](private_key)): + raise SaltInvocationError("Specified private key does not exist") + public_key = _check_ret( + __salt__["x509.get_public_key"](private_key, private_key_passphrase) + ) + elif public_key: + # todo usually can be specified as the key itself + if not _check_ret(__salt__["file.file_exists"](public_key)): + raise SaltInvocationError("Specified public key does not exist") + public_key = _check_ret(__salt__["x509.get_public_key"](public_key)) + elif csr: + # todo usually can be specified as the csr itself + if not _check_ret(__salt__["file.file_exists"](csr)): + raise SaltInvocationError("Specified csr does not exist") + csr = _check_ret(__salt__["hashutil.base64_encodefile"](csr)) + + if create_private_key: + # A missing private key means we need to create a certificate regardless + new_certificate = True + elif not _check_ret(__salt__["file.file_exists"](name)): + new_certificate = True + else: + # We check the certificate the same way the state does + crt = _check_ret(__salt__["hashutil.base64_encodefile"](name)) + signing_policy_contents = get_signing_policy( + signing_policy, ca_server=ca_server + ) + if "encoding" not in cert_args: + raise ValueError(cert_args) + current, cert_changes, replace, _ = x509util.check_cert_changes( + crt, + **cert_args, + ca_server=ca_server, + signing_policy_contents=signing_policy_contents, + public_key=public_key, + csr=csr, + ) + new_certificate = new_certificate or replace + reencode_certificate = bool(cert_changes) and not bool( + set(cert_changes) + - { + "additional_certs", + "encoding", + "pkcs12_friendlyname", + } + ) + + if pk_args and pk_args.get("new") and not create_private_key: + if new_certificate or (cert_changes and not reencode_certificate): + recreate_private_key = True + + if __opts__["test"]: + if pk_args: + pk_ret = { + "name": pk_args["name"], + "result": True, + "comment": "The private key is in the correct state", + "changes": {}, + "require_in": [ + name + "_crt", + ], + } + if create_private_key or recreate_private_key: + pp = "created" if not recreate_private_key else "recreated" + pk_ret["changes"][pp] = name + pk_ret["comment"] = f"The private key would have been {pp}" + ret[pk_args["name"] + "_key"] = { + "x509.private_key_managed_ssh": [{k: v} for k, v in pk_ret.items()] + } + ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].extend( + {k: v} for k, v in pk_file_args.items() + ) + + cert_ret = { + "name": name, + "result": True, + "changes": {}, + } + if new_certificate: + pp = ("re" if current else "") + "created" + cert_ret["comment"] = f"The certificate would have been {pp}" + cert_ret["changes"][pp] = name + elif reencode_certificate: + cert_ret["comment"] = "The certificate would have been reencoded" + cert_ret["changes"] = cert_changes + elif cert_changes: + cert_ret["comment"] = "The certificate would have been updated" + cert_ret["changes"] = cert_changes + else: + cert_ret["comment"] = "The certificate is in the correct state" + cert_ret["changes"] = {} + + ret[name + "_crt"] = { + "x509.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] + } + ret[name + "_crt"]["x509.certificate_managed_ssh"].extend( + {k: v} for k, v in cert_file_args.items() + ) + return ret + + if create_private_key or recreate_private_key: + pk_temp_file = _check_ret(__salt__["temp.file"]()) + _check_ret(__salt__["file.set_mode"](pk_temp_file, "0600")) + cpk_args = {"path": pk_temp_file} + for arg in ( + "algo", + "keysize", + "passphrase", + "encoding", + "pkcs12_encryption_compat", + ): + if arg in pk_args: + cpk_args[arg] = pk_args[arg] + _check_ret(__salt__["x509.create_private_key"](**cpk_args)) + public_key = _check_ret( + __salt__["x509.get_public_key"](pk_temp_file, pk_args.get("passphrase")) + ) + if pk_args: + pk_ret = { + "name": pk_args["name"], + "result": True, + "comment": "The private key is in the correct state", + "changes": {}, + "require_in": [ + name + "_crt", + ], + } + if create_private_key or recreate_private_key: + pp = "created" if not recreate_private_key else "recreated" + pk_ret["changes"][pp] = pk_args["name"] + pk_ret["comment"] = f"The private key has been {pp}" + ret[pk_args["name"] + "_key"] = { + "x509.private_key_managed_ssh": [{k: v} for k, v in pk_ret.items()] + } + ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].extend( + {k: v} for k, v in pk_file_args.items() + ) + ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].append( + {"tempfile": pk_temp_file} + ) + + cert_ret = { + "name": name, + "result": True, + "changes": {}, + "encoding": certificate_managed["encoding"], + } + if reencode_certificate: + cert_ret["contents"] = _check_ret( + __salt__["x509.encode_certificate"]( + x509util.to_pem(current), + encoding=certificate_managed["encoding"], + append_certs=certificate_managed["append_certs"], + private_key=pk_args["name"] if pk_args else private_key, + private_key_passphrase=pk_args.get("passphrase") + if pk_args + else private_key, + pkcs12_passphrase=certificate_managed.get("pkcs12_passphrase"), + pkcs12_encryption_compat=certificate_managed.get( + "pkcs12_encryption_compat" + ), + pkcs12_friendlyname=certificate_managed.get("pkcs12_friendlyname"), + raw=False, + ) + ) + cert_ret["comment"] = "The certificate has been reencoded" + cert_ret["changes"] = cert_changes + elif new_certificate or cert_changes: + pp = ("re" if current else "") + "created" + cert_ret["contents"] = create_certificate( + **_filter_cert_managed_state_args(cert_args), + ca_server=ca_server, + signing_policy=signing_policy, + csr=csr, + public_key=public_key, + ) + cert_ret["comment"] = f"The certificate has been {pp}" + if not cert_changes: + cert_ret["changes"][pp] = name + else: + cert_ret["changes"] = cert_changes + else: + cert_ret["comment"] = "The certificate is in the correct state" + cert_ret["changes"] = {} + + ret[name + "_crt"] = { + "x509.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] + } + ret[name + "_crt"]["x509.certificate_managed_ssh"].append( + {k: v} for k, v in cert_file_args.items() + ) + except (CommandExecutionError, SaltInvocationError) as err: + if pk_temp_file: + if _check_ret(__salt__["file.file_exists"](pk_temp_file)): + try: + # otherwise, get rid of it + _check_ret(__salt__["file.remove"](pk_temp_file)) + except Exception as err: # pylint: disable=broad-except + log.error(str(err), exc_info_on_loglevel=logging.DEBUG) + ret = { + name + + "_crt": { + "x509.certificate_managed_ssh": [ + {"name": name}, + {"result": False}, + {"comment": str(err)}, + {"changes": {}}, + ] + } + } + if pk_args and "name" in pk_args: + ret[pk_args["name"] + "_key"] = { + "x509.private_key_managed_ssh": [ + {"name": pk_args["name"]}, + {"result": False}, + {"comment": str(err)}, + {"changes": {}}, + ] + } + return ret + + +def _filter_cert_managed_state_args(kwargs): + return {k: v for k, v in kwargs.items() if k != "days_remaining"} diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index 2fb7b2e8cf52..667b1fd97922 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -251,6 +251,11 @@ def certificate_managed( This function accepts the same arguments as :py:func:`x509.create_certificate `, as well as most ones for `:py:func:`file.managed `. + .. note:: + + Since ``file.managed`` also has an ``encoding`` param, it can be passed + as ``file_encoding`` instead. + name The path the certificate should be present at. @@ -1470,6 +1475,51 @@ def private_key_managed( return ret +def certificate_managed_ssh( + name, result, comment, changes, encoding=None, contents=None, **kwargs +): + """ + Helper for the SSH wrapper module. + This receives a base64-encoded certificate and dumps the data to the target. + A ``file.managed`` sub-state run will be performed. + """ + ret = {"name": name, "result": result, "comment": comment, "changes": changes} + if not result: + return ret + file_managed_ret = _file_managed(name, replace=False, **kwargs) + _add_sub_state_run(ret, file_managed_ret) + if not _check_file_ret(file_managed_ret, ret, __salt__["file.file_exists"](name)): + return ret + if contents is not None and not __opts__["test"]: + if encoding in ("pem", "pkcs7_pem"): + contents = contents.encode() + else: + contents = base64.b64decode(contents) + _safe_atomic_write(name, contents, kwargs.get("backup", "")) + return ret + + +def private_key_managed_ssh(name, result, comment, changes, tempfile=None, **kwargs): + """ + Helper for the SSH wrapper module to report the correct return and + perform a ``file.managed`` sub-state run. + """ + ret = {"name": name, "result": result, "comment": comment, "changes": changes} + if not result: + return ret + file_managed_ret = _file_managed(name, replace=False, **kwargs) + if tempfile is not None: + try: + __salt__["file.move"](tempfile, name) + except Exception as err: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] += f". But: Failed moving the private key into place: {err}" + ret["changes"] = {} + return ret + _add_sub_state_run(ret, file_managed_ret) + return ret + + def _filter_state_internal_kwargs(kwargs): # check_cmd is a valid argument to file.managed ignore = set(_STATE_INTERNAL_KEYWORDS) - {"check_cmd"} diff --git a/salt/utils/x509.py b/salt/utils/x509.py index bc34be40b2a1..67dbd94135df 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -724,7 +724,7 @@ def split_file_kwargs(kwargs): "tmp_dir", "tmp_ext", "selinux", - "encoding", + "file_encoding", "encoding_errors", "win_owner", "win_perms", @@ -739,6 +739,8 @@ def split_file_kwargs(kwargs): file_args[k] = v else: extra_args[k] = v + if "file_encoding" in file_args: + file_args["encoding"] = file_args.pop("file_encoding") return file_args, extra_args diff --git a/tests/pytests/integration/ssh/x509_v2/conftest.py b/tests/pytests/integration/ssh/x509_v2/conftest.py new file mode 100644 index 000000000000..c908b590202d --- /dev/null +++ b/tests/pytests/integration/ssh/x509_v2/conftest.py @@ -0,0 +1,432 @@ +""" +Tests for the x509_v2 module +""" + +import logging +import shutil + +import pytest +from saltfactories.utils import random_string + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def x509_pkidir(tmp_path_factory): + _x509_pkidir = tmp_path_factory.mktemp("pki") + try: + yield _x509_pkidir + finally: + shutil.rmtree(str(_x509_pkidir), ignore_errors=True) + + +@pytest.fixture(scope="module", autouse=True) +def x509_data( + x509_pkidir, + rsa_privkey, + rsa_privkey_enc, + rsa_pubkey, + csr, +): + with pytest.helpers.temp_file("key", rsa_privkey, x509_pkidir) as privkey_file: + with pytest.helpers.temp_file("key_enc", rsa_privkey_enc, x509_pkidir): + with pytest.helpers.temp_file("key_pub", rsa_pubkey, x509_pkidir): + with pytest.helpers.temp_file("csr", csr, x509_pkidir): + yield privkey_file + + +@pytest.fixture(scope="module") +def x509_master_config(ca_minion_id): + enable_v2 = { + "features": { + "x509_v2": True, + } + } + return { + "open_mode": True, + "peer": { + ".*": [ + "x509.sign_remote_certificate", + ], + ca_minion_id: [ + "match.compound", + ], + }, + "ssh_minion_opts": enable_v2, + # It's necessary to enable it on the master as well, + # see the wrapper docs for details. + **enable_v2, + } + + +@pytest.fixture(scope="module") +def x509_salt_master(salt_factories, ca_minion_id, x509_master_config): + factory = salt_factories.salt_master_daemon( + "x509-master", defaults=x509_master_config + ) + with factory.started(): + yield factory + + +@pytest.fixture(scope="module") +def x509_salt_ssh_cli(x509_salt_master, salt_ssh_roster_file, sshd_config_dir): + return x509_salt_master.salt_ssh_cli( + timeout=180, + roster_file=salt_ssh_roster_file, + target_host="localhost", + client_key=str(sshd_config_dir / "client_key"), + base_script_args=["--ignore-host-keys"], + ) + + +@pytest.fixture(scope="module") +def ca_minion_id(): + return random_string("x509ca-minion", uppercase=False) + + +@pytest.fixture(scope="module") +def x509_minion_id(): + return "localhost" + + +@pytest.fixture(scope="module") +def ca_minion_config( + x509_minion_id, + ca_cert, + ca_key, + ca_key_enc, + x509_salt_ssh_cli, + ca_new_cert, + rsa_privkey, +): + return { + "open_mode": True, + "x509_signing_policies": { + "testpolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key, + "CN": "from_signing_policy", + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "authorityKeyIdentifier": "keyid:always", + "subjectKeyIdentifier": "hash", + }, + "testencpolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key_enc, + "signing_private_key_passphrase": "correct horse battery staple", + "CN": "from_signing_policy", + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "authorityKeyIdentifier": "keyid:always", + "subjectKeyIdentifier": "hash", + }, + "testmatchpolicy": { + "minions": x509_minion_id, + "signing_cert": ca_cert, + "signing_private_key": ca_key, + "CN": "from_matching_policy", + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "authorityKeyIdentifier": "keyid:always", + "subjectKeyIdentifier": "hash", + }, + "testmatchfailpolicy": { + "minions": "notallowed", + "CN": "from_matchfail_policy", + }, + "testextpolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key, + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "extendedKeyUsage": None, + "subjectKeyIdentifier": "hash", + "authorityKeyIdentifier": "keyid:always", + "issuerAltName": "DNS:salt.ca", + "authorityInfoAccess": "OCSP;URI:http://ocsp.salt.ca/", + "subjectAltName": "DNS:sub.salt.ca,email:sub@salt.ca", + "crlDistributionPoints": "URI:http://salt.ca/myca.crl", + "certificatePolicies": "1.2.4.5", + "policyConstraints": "requireExplicitPolicy:3", + "inhibitAnyPolicy": 2, + "nameConstraints": "permitted;IP:192.168.0.0/255.255.0.0,excluded;email:.com", + "noCheck": True, + "tlsfeature": "status_request", + }, + "testnosubjectpolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key, + "CN": "from_signing_policy", + }, + "testsubjectstrpolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key, + "subject": "CN=from_signing_policy", + }, + "testdeprecatednamepolicy": { + "commonName": "deprecated", + }, + "testdeprecatedextpolicy": { + "X509v3 Basic Constraints": "critical CA:FALSE", + }, + "testchangepolicy": { + "signing_cert": ca_cert, + "signing_private_key": ca_key_enc, + "signing_private_key_passphrase": "correct horse battery staple", + "CN": "from_changed_signing_policy", + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "authorityKeyIdentifier": "keyid:always", + "subjectKeyIdentifier": "hash", + }, + "testchangecapolicy": { + "signing_cert": ca_new_cert, + "signing_private_key": rsa_privkey, + "CN": "from_signing_policy", + "basicConstraints": "critical, CA:FALSE", + "keyUsage": "critical, cRLSign, keyCertSign", + "authorityKeyIdentifier": "keyid:always", + "subjectKeyIdentifier": "hash", + }, + }, + "features": { + "x509_v2": True, + }, + } + + +@pytest.fixture(scope="module", autouse=True) +def x509ca_salt_minion(x509_salt_master, ca_minion_id, ca_minion_config): + assert x509_salt_master.is_running() + factory = x509_salt_master.salt_minion_daemon( + ca_minion_id, + defaults=ca_minion_config, + ) + with factory.started(): + # Sync All + salt_call_cli = factory.salt_call_cli() + ret = salt_call_cli.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + yield factory + + +@pytest.fixture(scope="module") +def rsa_privkey(): + return """\ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAzIdEbSkbPIc5F/aewNoqWPsF/YP+DByMwvYs+0K+lehc39P8 +2fL8K2GIaGMBBzqlsX6CplAzGcoEQEBLTouLm+JYC5e1zRjaml4M+Bid8A7qwdjl +Wd0enCAxVB9BMNnj/mokWzh3hAQMBXfmddGRhH0P9KXfjBNh2V09vfHdtS9XMEEQ +jN6vCxaezXqsOMN3bjRTIcUatH7dVUgUpt9cye1mdbD5KVBgJ9MArc2tJ3rmB0lx +jEbAhTEHrNnIkDOJCKE8TaQOW4RyVWlIvSEL+Ov0TPeXXaef8HJlkyIpKrCZ+c4i +0N7qOlyrJEWTXmKNCj87xgTpY5I7ARISeOQD8QIDAQABAoIBABYNN4l1dyRNiBBX +XMJ6QzqYxgqRYqC3q02R8UOd7KKJDQps9sQg+KNMvsJSelfnMNo0Q63e08OiDldH +F1d+oCzMeKW3U7irR1aBcXCGZvDtCs6frgrEVnqK1ga13/d+ZqCVnRngurIXJZyp +UsW9NK1ONpwwDiwyIsimzvNd0oOoR6ROIN2Fk+AhKQ6bPdgqLM1Swx6BA0J/aaqO +jAqSkYkGOEL970W8ZhnyyDDRcbgPbacUDo7AJnrBeqHoAqrJ1PzJ3jhcWDJl8Xcy +uVDP1hBeK9yg4nuMcArsqrRQvqL2GuafGYygfzrU1aW96hlXciOv32ov36h2qIJU +r4JfJGECgYEA7UPD4iRsHV6eMkD98Ev74ygdnFL2TMknqOUEboPNiQzSzr5oVrKa +KFDhzenUNlMSoeiAaLLI7xaD4xptXuN8xx7sQZVSiEniBfJ7F+9sPNjCXwYbUuWp +qpp6KfCrjLxDxgSKH9FUIlTvL7M4lmAD2yHn4zXjFz3BOs261JUn6l0CgYEA3K2/ +S2eP3VUL6K4+HNMzXTj9Q8S7LSYnTZVIjfek6pQHMwaMKE8EC7L4XeS9TZ49BKCS +Mh9RI2yBCX6L1uo2zURAI0oDrowDhjaUCD4xxTD27OyMcvjdSzk/+0E+DtsWdgYm +FGX/l0zTRUsZBbc7ItTG0ksIB+aMM4njBbHubqUCgYAq9llS6pt1Gfv1R5Vz3J5o +vIvYEaGtt8Lpr0aFKHKgPWUysIG+KSsG39ZzbcLSb2pxTONrkewWdvI8vj1NsE2Y +1L2dBofiS9aUkxq888qanflcMYPjF9kIHl6+l2jI3BI9mfbU2hes+8ovzfkSKaKp +HFOb7dcID1Oc7UHGWpfWtQKBgQDC3Y4xOKbaLDJS6iIg9ALETAGgqQUbzjggkzU5 +X7e6CLL+xMZZBcUty4Dz8HuVIakCAAR4zByq6jJbvuofAj0YLy9vufjcVfj6uBEp +4jmyxhUVi6BOGiHXPhuYc7koByCjYbSYiKUU5psc8j6LRIysqjVTFzxlNZkSHa1h +pwhDnQKBgATpQou7MeAOMHjMPaNx8OCq7QNhocp8Q+goxPb0ND2jF9xSI+gjzRRt +Kpz+xO6tri6wCgWrmE5cJbEe3/EYf3bmbNA9wOQ72kfoy9uO0cCi+5gSJigwaIKM +DYRTDIS9eg2LF4B64hZvkCLTmP4rLJWdRnWrLosIC4rD1uWgGayC +-----END RSA PRIVATE KEY-----""" + + +@pytest.fixture(scope="module") +def rsa_privkey_enc(): + return """\ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIHU2H6hhL0gYCAggA +MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBD64PydhZIJPW9amw7M8yGvBIIE +0LHXvvQleCJMlH/Rtml1Vx2nygReVl+1Ag+FjtsNQHtsXYkzVWSDI0zI7nFyDpb9 +Kr2+9UOsOhQA5/swka9ude4oJng0YZcV4qgar8yFncWTrMTk/mrvFSNZPz9LMGsq +in7hzYGAP6XdprHgJfw+wDQfwbwcTQp5DUOPYbhxfnggVQBL84gp/2urCcNnFX+T +OKGm9C3NfLycrCbaQxaV/2oTo7+UHUaXKwZwY6zKxCqbwGBy7dNcZD16nJyOBmbj +ytOi/OqBcoj03yK4ETIm7EWwem6CRAbPH1GnUAxmb5tG6jzKphbMJur8n72Vv+VK +9+Gkz5vOq1O1wlK+DfB+Xrgfx3lHHQllxi7FtlQegSFlIbHAacG/muwMRQ5PoMEp +RaGQkxOhiU7VSaZ3Gdx3TrQMaF5nBqvs90Xw40uWdD9+Kd3Oqkj9OgiqHZwgWPfW +txB+jXYGj1ERUvb36T7P8IH/QDa8jwVf3+f1pOpoMe4+6i3rr9bAkDhIjpNDo2a0 +YXvVns18UisnLXHxdAZb9R2V/VoTxhs3IqK3nEb5qnb1RAtJfV4p1ENVsoPiHl5C +pq7xcRO+25hy18CjMWqj8t3PH5MdBL8UMFZyDnIH9z9N019U0ZIaD3NqiiRgGD+U +CSLkoXq5oni5RkDQCnzJRFo/Vzmx2P5OJyZvHYLtVOUwsp1dW8JFtdKJoGBxNc1M +kc7eevfwUZEDc2dHxcwxDj0Tas05DaMBib3Oi0D/ipxDdzW+uENQHdCwy7XZf+T+ +ig03Ega0/w+c/rdnUevdXK/L1sIO7F8hyDlVG1q0PeoJ8jXnZk+UfNYy820sPWIE +IwtT1aODvnYgio8vgrDXpB0qVDNi2Ml83gYxznIQuxWg6dCrifvCa8TwCTe9tAhv +gTkEkYdyBTpvT585z/1x+dra3uOGiMCN0rP3n3JaICDqCwImznvIP8kqNEnalWQj +pUVI3nKZunTtrL9vAegW9jF0Ipvyf+VSQmw+yN5B35Qfy95CwAwtJ/HPjy1sZmJZ +carKrlqoD4xdSyrIun3fraGTbM+u4S+USRjikce+pu1cHi70Y3xm4JBAZsRJgPwB +G/Orf5yC+E2pCK+7rX3rWINgwmX/kk94EtnYbMeES+lhlKOu/mR09K00atuBEDnJ +o0MCM0BWYy5XQ2RAJLKCdcuJ2aWs/+slKRzlTCWnCUgISng6KFpcyA0aS/8r3ZyH +SKdoSSgOtAieE/TGll0wjvONMIMfoEgR40OBV8BCSF8zWASZBXASTTSlUcu2wQ0q +/wPFS2KkBdBc+qr+TxDNoeFDX+Rh9Nai25O/xoRtCC7afHsd5aQ4yen5C34/jsR1 +2kuayvZJ2pgYfIobFdgq9qHi637dVeW8n09XRq6HWhZu1ODO5bGX2oLr64MJAmgi +fA+zu5Dfoe2Q4N1Ja3y0M7Xpfws14jyFxnJ8dR/T6rIJOy1QtHGo3UTai8nSBqCP +RJ766EKBW7j83/53aYyChHvTXEPf4C29iOur72iMAlT2S06K/SH4fFM3brBzz0Fq +EykXIgConLXDwj9+87XKYmOQX/0UP2sxAno6gJakdzExIod+u5koXP1o9vL5zMlH +ahZPgPpP2p2uAz1+9MHpVPo2EIrvibm5T89DznwuaEfe +-----END ENCRYPTED PRIVATE KEY-----""" + + +@pytest.fixture(scope="module") +def rsa_pubkey(): + return """\ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzIdEbSkbPIc5F/aewNoq +WPsF/YP+DByMwvYs+0K+lehc39P82fL8K2GIaGMBBzqlsX6CplAzGcoEQEBLTouL +m+JYC5e1zRjaml4M+Bid8A7qwdjlWd0enCAxVB9BMNnj/mokWzh3hAQMBXfmddGR +hH0P9KXfjBNh2V09vfHdtS9XMEEQjN6vCxaezXqsOMN3bjRTIcUatH7dVUgUpt9c +ye1mdbD5KVBgJ9MArc2tJ3rmB0lxjEbAhTEHrNnIkDOJCKE8TaQOW4RyVWlIvSEL ++Ov0TPeXXaef8HJlkyIpKrCZ+c4i0N7qOlyrJEWTXmKNCj87xgTpY5I7ARISeOQD +8QIDAQAB +-----END PUBLIC KEY-----""" + + +@pytest.fixture(scope="module") +def csr(): + return """\ +-----BEGIN CERTIFICATE REQUEST----- +MIICRTCCAS0CAQAwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMyH +RG0pGzyHORf2nsDaKlj7Bf2D/gwcjML2LPtCvpXoXN/T/Nny/CthiGhjAQc6pbF+ +gqZQMxnKBEBAS06Li5viWAuXtc0Y2ppeDPgYnfAO6sHY5VndHpwgMVQfQTDZ4/5q +JFs4d4QEDAV35nXRkYR9D/Sl34wTYdldPb3x3bUvVzBBEIzerwsWns16rDjDd240 +UyHFGrR+3VVIFKbfXMntZnWw+SlQYCfTAK3NrSd65gdJcYxGwIUxB6zZyJAziQih +PE2kDluEclVpSL0hC/jr9Ez3l12nn/ByZZMiKSqwmfnOItDe6jpcqyRFk15ijQo/ +O8YE6WOSOwESEnjkA/ECAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQB9PbGDorNt +Tl4xYObUsQwUkMVRPI59MLLYKEJRu/DGSA4sKf/vLK1ypyLIvxNp4gNFgm28nDV2 +t2gQ+DpBvwC1+XZQDZjgL7pPtLvErGCs6O6Y5fW8Lywxx5GqiVTIic/XLKTijKJv +EecvwPjWv1VgtBKLZxN18KgIIs2Sq/t+GYe+Lu30c92Lc5INbrwTIEDYNTHywKet +8FTSaYEMU6sGgsrIC5VxNT00EgJHjyjdCVIqQr/LqKyBMqJICWUSPq2ufjwqFsFi +q1HXd62bA8k27ukX7w8qWsk6fOTwPh5F3883L5jVqcRsL9pqb4RUugTh/aReVlKW +0WMDRBksXs1E +-----END CERTIFICATE REQUEST-----""" + + +@pytest.fixture(scope="module") +def ca_cert(): + # the final newline here is important since it is compared + # with the ca_server return, which is parsed to contain one + return """\ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIIbfpgqP0VGPgwDQYJKoZIhvcNAQELBQAwKzELMAkGA1UE +BhMCVVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHQwHhcNMjIxMTE1MTQw +NDMzWhcNMzIxMTEyMTQwNDMzWjArMQswCQYDVQQGEwJVUzENMAsGA1UEAwwEVGVz +dDENMAsGA1UECgwEU2FsdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOGTScvrjcEt6vsJcG9RUp6fKaDNDWZnJET0omanK9ZwaoGpJPp8UDYe/8ADeI7N +10wdyB4oDM9gRDjInBtdQO/PsrmKZF6LzqVFgLMxu2up+PHMi9z6B2P4esIAzMu9 +PYxc9zH4HzLImHqscVD2HCabsjp9X134Af7hVY5NN/W/4qTP7uOM20wSG2TPI6+B +tA9VyPbEPMPRzXzrqc45rVYe6kb2bT84GE93Vcu/e5JZ/k2AKD8Hoa2cxLPsTLq5 +igl+D+k+dfUtiABiKPvVQiYBsD1fyHDn2m7B6pCgvrGqHjsoAKufgFnXy6PJRg7n +vQfaxSiusM5s+VS+fjlvgwsCAwEAAaNgMF4wDwYDVR0TBAgwBgEB/wIBATALBgNV +HQ8EBAMCAQYwHQYDVR0OBBYEFFzy8fRTKSOe7kBakqO0Ki71potnMB8GA1UdIwQY +MBaAFFzy8fRTKSOe7kBakqO0Ki71potnMA0GCSqGSIb3DQEBCwUAA4IBAQBZS4MP +fXYPoGZ66seM+0eikScZHirbRe8vHxHkujnTBUjQITKm86WeQgeBCD2pobgBGZtt +5YFozM4cERqY7/1BdemUxFvPmMFFznt0TM5w+DfGWVK8un6SYwHnmBbnkWgX4Srm +GsL0HHWxVXkGnFGFk6Sbo3vnN7CpkpQTWFqeQQ5rHOw91pt7KnNZwc6I3ZjrCUHJ ++UmKKrga16a4Q+8FBpYdphQU609npo/0zuaE6FyiJYlW3tG+mlbbNgzY/+eUaxt2 +9Bp9mtA+Hkox551Mfpq45Oi+ehwMt0xjZCjuFCM78oiUdHCGO+EmcT7ogiYALiOF +LN1w5sybsYwIw6QN +-----END CERTIFICATE----- +""" + + +@pytest.fixture(scope="module") +def ca_new_cert(): + return """\ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUd9vWUmDYMY7l3hSOwMV4UO36zfMwDQYJKoZIhvcNAQEL +BQAwKzELMAkGA1UEBhMCVVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHQw +HhcNMjIxMTIxMTUyOTE1WhcNMjIxMjIxMTUyOTE1WjAbMRkwFwYDVQQDDBBTYWx0 +IG5ldyB0ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzIdE +bSkbPIc5F/aewNoqWPsF/YP+DByMwvYs+0K+lehc39P82fL8K2GIaGMBBzqlsX6C +plAzGcoEQEBLTouLm+JYC5e1zRjaml4M+Bid8A7qwdjlWd0enCAxVB9BMNnj/mok +Wzh3hAQMBXfmddGRhH0P9KXfjBNh2V09vfHdtS9XMEEQjN6vCxaezXqsOMN3bjRT +IcUatH7dVUgUpt9cye1mdbD5KVBgJ9MArc2tJ3rmB0lxjEbAhTEHrNnIkDOJCKE8 +TaQOW4RyVWlIvSEL+Ov0TPeXXaef8HJlkyIpKrCZ+c4i0N7qOlyrJEWTXmKNCj87 +xgTpY5I7ARISeOQD8QIDAQABo2AwXjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE +AwIBBjAdBgNVHQ4EFgQUkLZONoQf6p8T2tLmMuWJG3iSmeQwHwYDVR0jBBgwFoAU +XPLx9FMpI57uQFqSo7QqLvWmi2cwDQYJKoZIhvcNAQELBQADggEBAI3P5XQAWV79 +7/8ARx8IW4zhn7MTwBSO7wlViQ9bNF0DFrMspajyUrtLVX1gg4UAhVon3dmHf/eq +wt+oT6YjmDAhR3mWUl1kp/LvE6EQgAnpmoOWe3PjZOiyE11651zO9pMF68FHdsin +TjKXsggEXTllc03VomuXDUvxz2nMh1TIUVdAFH+eDykp5EFqGmXvPEJ7eVUv6v/m +rXM0cUNy6MAjQM49slAxU03yRBD+oBauaVewrg+HKmY9FuzYga46rcQciOxRbPyl +FokHBycPfCXe7QyPLZe9Kqth5BWXdhyyqvKSErUV6glxiShVxX0ciDNziBfU9UCY +TMwH/TREa3g= +-----END CERTIFICATE-----""" + + +@pytest.fixture(scope="module") +def ca_key(): + return """\ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA4ZNJy+uNwS3q+wlwb1FSnp8poM0NZmckRPSiZqcr1nBqgakk ++nxQNh7/wAN4js3XTB3IHigMz2BEOMicG11A78+yuYpkXovOpUWAszG7a6n48cyL +3PoHY/h6wgDMy709jFz3MfgfMsiYeqxxUPYcJpuyOn1fXfgB/uFVjk039b/ipM/u +44zbTBIbZM8jr4G0D1XI9sQ8w9HNfOupzjmtVh7qRvZtPzgYT3dVy797kln+TYAo +PwehrZzEs+xMurmKCX4P6T519S2IAGIo+9VCJgGwPV/IcOfabsHqkKC+saoeOygA +q5+AWdfLo8lGDue9B9rFKK6wzmz5VL5+OW+DCwIDAQABAoIBAFfImc9hu6iR1gAb +jEXFwAE6r1iEc9KGEPdEvG52X/jzhn8u89UGy7BEIAL5VtE8Caz1agtSSqnpLKNs +blO31q18hnDuCmFAxwpKIeuaTvV3EAoJL+Su6HFfIWaeKRSgcHNPOmOXy4xXw/75 +XJ/FJu9fZ9ybLaHEAgLObh0Sr9RSPQbZ72ZawPP8+5WCbR+2w90RApHXQL0piSbW +lIx1NE6o5wQb3vik8z/k5FqLCY2a8++WNyfvS+WWFY5WXGI7ZiDDQk46gnslquH2 +Lon5CEn3JlTGQFhxaaa2ivssscf2lA2Rvm2E8o1rdZJS2OpSE0ai4TXY9XnyjZj1 +5usWIwECgYEA+3Mwu03A7PyLEBksS/u3MSo/176S9lF/uXcecQNdhAIalUZ8AgV3 +7HP2yI9ZC0ekA809ZzFjGFostXm9VfUOEZ549jLOMzvBtCdaI0aBUE8icu52fX4r +fT2NY6hYgz5/fxD8sq1XH/fqNNexABwtViH6YAly/9A1/8M3BOWt72UCgYEA5ag8 +sIfiBUoWd1sS6qHDuugWlpx4ZWYC/59XEJyCN2wioP8qFji/aNZxF1wLfyQe/zaa +YBFusjsBnSfBU1p4UKCRHWQ9/CnC0DzqTkyKC4Fv8GuxgywNm5W9gPKk7idHP7mw +e+7Uvf1pOQccqEPh7yltpW+Xw27gfsC2DMAIGa8CgYByv/q5P56PiCCeVB6W/mR3 +l2RTPLEsn7y+EtJdmL+QgrVG8kedVImJ6tHwbRqhvyvmYD9pXGxwrJZCqy/wjkjB +WaSyFjVrxBV99Yd5Ga/hyntaH+ELHA0UtoZTuHvMSTU9866ei+R6vlSvkM9B0ZoO ++KqeMTG99HLwKVJudbKO0QKBgQCd33U49XBOqoufKSBr4yAmUH2Ws6GgMuxExUiY +xr5NUyzK+B36gLA0ZZYAtOnCURZt4x9kgxdRtnZ5jma74ilrY7XeOpbRzfN6KyX3 +BW6wUh6da6rvvUztc5Z+Gk9+18mG6SOFTr04jgfTiCwPD/s06YnSfFAbrRDukZOU +WD45SQKBgBvjSwl3AbPoJnRjZjGuCUMKQKrLm30xCeorxasu+di/4YV5Yd8VUjaO +mYyqXW6bQndKLuXT+AXtCd/Xt2sI96z8mc0G5fImDUxQjMUuS3RyQK357cEOu8Zy +HdI7Pfaf/l0HozAw/Al+LXbpmSBdfmz0U/EGAKRqXMW5+vQ7XHXD +-----END RSA PRIVATE KEY-----""" + + +@pytest.fixture(scope="module") +def ca_key_enc(): + return """\ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIy/O+FhcKBKUCAggA +MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDtSfZzKh7brkHFw/s6bcbVBIIE +0JcLyycDhdSPzL7Zm1+ZLavjxiuaGEaHU8hu8ZScqyjcdWbdOfOuqZgu7OzxwfIc +8Q1bfqMGUfxPcs/JQh13CVOaDYmafeMZYN3rqsNoci11iaHDhTAqgYCM2iVXaFUt +6ZdfW+/hEk+yHwK5K2R1/ks8buAe0OgjkV0N3DqAif93BPyFP6XT7btVMrorGJjh +1OJjuw3q0xJ02rn7O5imaZ5NnCIDShkKwWO6sUew3QHhW61/nuCBPyJTsAO0L4+t +9zjb2jOIIuvTpZUhAty6I+bKgaYLhsii7z5jVYpt+NbYpzIe+9RvAD1psGk9+bGD +rN70Bnhx29mPEKdmozXVQ8GTqDOSQSYMr9aax+BhSJoTnCtVtGGX0LXE5Dvd/HHy ++Yw2HFrVglptsPYo4EBKccC3FJlS0mL6yBW5NCpU7MOhDV/iOMbzM4bqwKG+jqaw +sjIScCg+ljBxGhNrcMa0AEBWukTRe4gERpb8AyGKYOSVN6iZyP5qhN/Abu1asKrj +c4NRUu3yILleZuxjkDd4w0CwhjlCaKFLsp1XeFE5ZHM5Iezi1/I4QMXFTydB1KnX +xOSofZ7b7pnvOiBQG2nQzYSjSnBO7E7NQOhjvkRgcxNsdAQWADIqdE3bKZ8qcEZ6 +q1TE0XtcDgwFGwQF/cyuEEXyMAkQV687e8IdCjc+MbyyqUtQA9382JyjOuzavvMD +nO5s80lB5aa0WHdE+Rg7KiBIwL1CjBSGSiggKvkG01ObeQL4DCQG6gHgz+nvdiNe +du2u6hW2/PUuUIOM2ApE98T2TAzCnyu02iMIN5aH4za5y1w5YzaU4Lsl4nzAEA3c +8EuVIWMutZnqT4ZSCLCq1AtDYkSXxIjGQPwhRslyCJuwtuiaDXLIZIpMRGqMKdGS +c3q0k5ba92jXppIOVYN/kViNjYeHVZ3KRAi2MqUByqiMBkZo11NsgaU/uPsKsK16 +D0XueVs9EobU55tgBV71Q8g/5BiGG19W5UZVzjiiuGuj44msOfYV4027KqqFf302 +U5RXAwBko9S+v3SuTZrRXK4uuYceR9Uyco8aP/tNAhHEGa8Z73vLngZICp57qD1h +8smjOrm1volZpu31HP9CWVh47GyuzSZ8BUFrR/uXfa+uqyLqeBKglz5SC6Ak3nL8 +eAHu3EK2dVp4vqwYB2oO9DQqs4CN7DKyArNeUzKSf6ZKEYBZCdF5V5HgbSpY5f+e +xj5cpuMVc7s+Nxv/0bqxNzt8ghe2sDELxK8lo7Q6E+aUNBWt++nHI2b8y5ynaANU +kQjeoorrPHUScXN8TVrgrIYIfXOqkI14UmroRH5/oyORHXN25JekV1DisKZOtSdV +Vqt3o/hlGFYhaeznIgquBm27trLkLHOfCGx6M2xlKszlWBP03zFLp0PiXE+y07zC +IwzaiVlj/O+QIsiMmrtc8WXYiNWVN5XDe1elFPs1K2cw0cIeyLgC1Bibxa7dH01G +Z0Nr+hZN+/EqI3Tu+lWeWtj/lIhjJrKQvUOMM4W1MFZZdK09ZsCdW0Y1fFYn/3Xz +g1KvGcFoszp0uMptlJUhsxtFooG4xKtgEITmtraRU+hTGU3NZgtk7Qff4tFa0O0h +A62orBDc+8x+AehfwYSm11dz5/P6aL3QZf+tzr05vbVn +-----END ENCRYPTED PRIVATE KEY-----""" diff --git a/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py b/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py new file mode 100644 index 000000000000..bce0f239697e --- /dev/null +++ b/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py @@ -0,0 +1,338 @@ +import base64 +from pathlib import Path + +import pytest + +try: + import cryptography + import cryptography.x509 as cx509 + from cryptography.hazmat.primitives.serialization import ( + load_der_private_key, + load_pem_private_key, + pkcs7, + pkcs12, + ) + + import salt.utils.x509 as x509util + + HAS_LIBS = True +except ImportError: + HAS_LIBS = False + + +CRYPTOGRAPHY_VERSION = tuple(int(x) for x in cryptography.__version__.split(".")) + + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skipif(HAS_LIBS is False, reason="Needs cryptography library"), +] + + +@pytest.fixture +def cert_args(ca_minion_id, tmp_path, x509_data): + return { + "name": str(tmp_path / "cert_managed"), + "ca_server": ca_minion_id, + "signing_policy": "testpolicy", + "private_key": str(x509_data), + "certificate_managed": { + "CA": "from_args", + }, + } + + +@pytest.fixture +def cert_args_exts(): + return { + "basicConstraints": "critical, CA:TRUE, pathlen:1", + "keyUsage": "critical, cRLSign, keyCertSign, digitalSignature", + "extendedKeyUsage": "OCSPSigning", + "subjectKeyIdentifier": "hash", + "authorityKeyIdentifier": "keyid:always", + "issuerAltName": "DNS:mysalt.ca", + "authorityInfoAccess": "OCSP;URI:http://ocsp.salt.ca/", + "subjectAltName": "DNS:me.salt.ca", + "crlDistributionPoints": None, + "certificatePolicies": "1.2.4.5", + "policyConstraints": "requireExplicitPolicy:3", + "inhibitAnyPolicy": 2, + "nameConstraints": "permitted;IP:192.168.0.0/255.255.0.0,excluded;email:.com", + "noCheck": True, + "tlsfeature": "status_request", + } + + +@pytest.fixture(scope="module", autouse=True) +def cm_wrapper(x509_salt_master): + state_contents = """ + {{ + salt["x509.certificate_managed_wrapper"]( + pillar["args"]["name"], + ca_server=pillar["args"]["ca_server"], + signing_policy=pillar["args"]["signing_policy"], + private_key_managed=pillar["args"].get("private_key_managed"), + private_key=pillar["args"].get("private_key"), + private_key_passphrase=pillar["args"].get("private_key_passphrase"), + csr=pillar["args"].get("csr"), + public_key=pillar["args"].get("public_key"), + certificate_managed=pillar["args"].get("certificate_managed"), + ) | yaml(false) + }} + """ + with x509_salt_master.state_tree.base.temp_file("cert.sls", state_contents): + yield + + +@pytest.fixture +def pk_tgt(tmp_path): + return str(tmp_path / "managed_key") + + +@pytest.fixture(params=[{}]) +def existing_cert(x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey, request, pk_tgt): + cert_managed_params = request.param.pop("certificate_managed", {}) + pk_managed = {} + exp_key = rsa_privkey + if "private_key_managed" in request.param: + pk_managed = {"private_key_managed": request.param.pop("private_key_managed")} + pk_managed["private_key_managed"]["name"] = pk_tgt + exp_key = pk_tgt + cert_args.update(request.param) + cert_args.update(pk_managed) + cert_args["certificate_managed"].update(cert_managed_params) + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, exp_key) + yield cert_args["name"] + + +def test_certificate_managed_remote(x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey): + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_certificate_managed_remote_with_privkey_managed( + x509_salt_ssh_cli, cert_args, tmp_path, ca_key +): + pk_args = {"name": str(tmp_path / "newkey")} + cert_args["private_key_managed"] = pk_args + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + privkey = _get_privkey(pk_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, privkey) + assert ret.data + assert len(ret.data) == 4 + for state in ret.data: + # file.managed creates the files before moving data into them + assert ret.data[state]["changes"] + + +@pytest.mark.usefixtures("existing_cert") +def test_certificate_managed_remote_no_changes( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + assert ret.data[next(iter(ret.data))]["changes"] == {} + + +@pytest.mark.usefixtures("existing_cert") +@pytest.mark.parametrize("existing_cert", ({"private_key_managed": {}},), indirect=True) +def test_certificate_managed_remote_no_changes_with_privkey_managed( + x509_salt_ssh_cli, cert_args, ca_key, pk_tgt +): + privkey = _get_privkey(pk_tgt) + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, privkey) + assert ret.data + assert len(ret.data) == 4 + for state in ret.data: + assert ret.data[state]["changes"] == {} + + +@pytest.mark.usefixtures("existing_cert") +def test_certificate_managed_remote_policy_change( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["signing_policy"] = "testchangepolicy" + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + assert "subject_name" in ret.data[next(iter(ret.data))]["changes"] + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_changed_signing_policy" + + +@pytest.mark.usefixtures("existing_cert") +@pytest.mark.parametrize("existing_cert", ({"private_key_managed": {}},), indirect=True) +def test_certificate_managed_remote_policy_change_with_privkey_managed( + x509_salt_ssh_cli, cert_args, ca_key, pk_tgt +): + privkey = _get_privkey(pk_tgt) + cert_args["signing_policy"] = "testchangepolicy" + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_changed_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, privkey) + assert ret.data + assert len(ret.data) == 4 + for state in ret.data: + if state.startswith("x509"): + if state.endswith("private_key_managed_ssh"): + assert not ret.data[state]["changes"] + else: + assert "subject_name" in ret.data[state]["changes"] + else: + # file sub state runs + assert not ret.data[state]["changes"] + + +@pytest.mark.usefixtures("existing_cert") +@pytest.mark.parametrize( + "existing_cert", ({"private_key_managed": {"new": True}},), indirect=True +) +def test_certificate_managed_remote_policy_change_with_privkey_managed_new( + x509_salt_ssh_cli, cert_args, ca_key, pk_tgt +): + privkey = _get_privkey(pk_tgt) + cert_args["signing_policy"] = "testchangepolicy" + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_changed_signing_policy" + assert _signed_by(cert, ca_key) + assert not _belongs_to(cert, privkey) + new_privkey = _get_privkey(pk_tgt) + assert _belongs_to(cert, new_privkey) + assert ret.data + assert len(ret.data) == 4 + for state in ret.data: + if state.startswith("x509"): + if state.endswith("private_key_managed_ssh"): + assert ret.data[state]["changes"] + else: + assert "subject_name" in ret.data[state]["changes"] + else: + # file sub state runs + assert not ret.data[state]["changes"] + + +@pytest.mark.usefixtures("existing_cert") +def test_certificate_managed_remote_ca_cert_change( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["signing_policy"] = "testchangecapolicy" + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + assert ret.data + changes = ret.data[next(iter(ret.data))]["changes"] + assert changes + assert "signing_private_key" in changes + assert "issuer_name" in changes + assert "extensions" in changes + assert "authorityKeyIdentifier" in changes["extensions"]["changed"] + + +@pytest.mark.usefixtures("existing_cert") +def test_certificate_managed_remote_no_changes_signing_policy_override( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["certificate_managed"][ + "basicConstraints" + ] = "critical, CA:TRUE, pathlen:3" + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + assert ret.data[next(iter(ret.data))]["changes"] == {} + + +@pytest.mark.usefixtures("existing_cert") +def test_certificate_managed_remote_renew(x509_salt_ssh_cli, cert_args): + cert_cur = _get_cert(cert_args["name"]) + cert_args["certificate_managed"]["days_remaining"] = 999 + ret = x509_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert_new = _get_cert(cert_args["name"]) + assert cert_new.serial_number != cert_cur.serial_number + + +def _belongs_to(cert_or_pubkey, privkey): + if isinstance(cert_or_pubkey, cx509.Certificate): + cert_or_pubkey = cert_or_pubkey.public_key() + return x509util.is_pair(cert_or_pubkey, x509util.load_privkey(privkey)) + + +def _signed_by(cert, privkey): + return x509util.verify_signature(cert, x509util.load_privkey(privkey).public_key()) + + +def _get_cert(cert, encoding="pem", passphrase=None): + try: + p = Path(cert) + if p.exists(): + cert = p.read_bytes() + except Exception: # pylint: disable=broad-except + pass + + if encoding == "pem": + if not isinstance(cert, bytes): + cert = cert.encode() + return cx509.load_pem_x509_certificate(cert) + if encoding == "der": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + return cx509.load_der_x509_certificate(cert) + if encoding == "pkcs7_pem": + if not isinstance(cert, bytes): + cert = cert.encode() + return pkcs7.load_pem_pkcs7_certificates(cert) + if encoding == "pkcs7_der": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + return pkcs7.load_der_pkcs7_certificates(cert) + if encoding == "pkcs12": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + if passphrase is not None and not isinstance(passphrase, bytes): + passphrase = passphrase.encode() + return pkcs12.load_pkcs12(cert, passphrase) + + +def _get_privkey(pk, encoding="pem", passphrase=None): + try: + p = Path(pk) + if p.exists(): + pk = p.read_bytes() + except Exception: # pylint: disable=broad-except + pass + if passphrase is not None: + passphrase = passphrase.encode() + + if encoding == "pem": + if not isinstance(pk, bytes): + pk = pk.encode() + return load_pem_private_key(pk, passphrase) + if encoding == "der": + if not isinstance(pk, bytes): + pk = base64.b64decode(pk) + return load_der_private_key(pk, passphrase) + if encoding == "pkcs12": + if not isinstance(pk, bytes): + pk = base64.b64decode(pk) + return pkcs12.load_pkcs12(pk, passphrase).key + raise ValueError("Need correct encoding") diff --git a/tests/pytests/integration/ssh/x509_v2/test_create_certificate.py b/tests/pytests/integration/ssh/x509_v2/test_create_certificate.py new file mode 100644 index 000000000000..86730cc9c359 --- /dev/null +++ b/tests/pytests/integration/ssh/x509_v2/test_create_certificate.py @@ -0,0 +1,325 @@ +""" +Tests for the x509_v2 module +""" + +import base64 +import copy +import logging +from pathlib import Path + +import pytest + +from salt.defaults.exitcodes import EX_AGGREGATE + +try: + import cryptography + import cryptography.x509 as cx509 + from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 + + import salt.utils.x509 as x509util + + HAS_LIBS = True +except ImportError: + HAS_LIBS = False + +CRYPTOGRAPHY_VERSION = tuple(int(x) for x in cryptography.__version__.split(".")) + +log = logging.getLogger(__name__) + + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skipif(HAS_LIBS is False, reason="Needs cryptography library"), +] + + +@pytest.fixture +def cert_args(ca_minion_id, x509_data): + return { + "ca_server": ca_minion_id, + "signing_policy": "testpolicy", + "private_key": str(x509_data), + "CA": "from_args", + } + + +@pytest.fixture +def cert_args_exts(): + return { + "basicConstraints": "critical, CA:TRUE, pathlen:1", + "keyUsage": "critical, cRLSign, keyCertSign, digitalSignature", + "extendedKeyUsage": "OCSPSigning", + "subjectKeyIdentifier": "hash", + "authorityKeyIdentifier": "keyid:always", + "issuerAltName": "DNS:mysalt.ca", + "authorityInfoAccess": "OCSP;URI:http://ocsp.salt.ca/", + "subjectAltName": "DNS:me.salt.ca", + "crlDistributionPoints": None, + "certificatePolicies": "1.2.4.5", + "policyConstraints": "requireExplicitPolicy:3", + "inhibitAnyPolicy": 2, + "nameConstraints": "permitted;IP:192.168.0.0/255.255.0.0,excluded;email:.com", + "noCheck": True, + "tlsfeature": "status_request", + } + + +def test_sign_remote_certificate(x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey): + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_sign_remote_certificate_match( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["signing_policy"] = "testmatchpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_matching_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +# Compound matching fails since +# a) before using the match runner: peer calls cannot target SSH minions +# b) after using the match runner: the SSH minion data is not cached as usual +# We cannot check though since the expression is only present on the CA minion. + + +def test_sign_remote_certificate_enc(x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey): + cert_args["private_key"] += "_enc" + cert_args["private_key_passphrase"] = "hunter2" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_sign_remote_certificate_ca_enc( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["signing_policy"] = "testencpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_sign_remote_certificate_pubkey( + x509_salt_ssh_cli, cert_args, x509_data, ca_key, rsa_privkey +): + cert_args.pop("private_key") + cert_args["public_key"] = str(x509_data.parent / "key_pub") + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_sign_remote_certificate_csr( + x509_salt_ssh_cli, cert_args, x509_data, ca_key, rsa_privkey +): + cert_args.pop("private_key") + cert_args["csr"] = str(x509_data.parent / "csr") + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_sign_remote_certificate_nonexistent_policy(x509_salt_ssh_cli, cert_args): + cert_args["signing_policy"] = "missingpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == EX_AGGREGATE + assert ret.data + assert "signing_policy must be specified and defined" in ret.data + + +def test_sign_remote_certificate_disallowed_policy(x509_salt_ssh_cli, cert_args): + cert_args["signing_policy"] = "testmatchfailpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == EX_AGGREGATE + assert ret.data + assert "minion not permitted to use specified signing policy" in ret.data + + +@pytest.mark.skipif( + CRYPTOGRAPHY_VERSION[0] < 37, + reason="Parsing of RFC4514 strings requires cryptography >= 37", +) +def test_sign_remote_certificate_no_subject_override( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + """ + Ensure that kwargs from remote requests are overridden + by signing policies as is done for regular ones + """ + cert_args["subject"] = {"O": "from_call"} + cert_args["signing_policy"] = "testsubjectstrpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +@pytest.mark.skipif( + CRYPTOGRAPHY_VERSION[0] < 37, + reason="Parsing of RFC4514 strings requires cryptography >= 37", +) +def test_sign_remote_certificate_no_name_attribute_override( + x509_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + """ + Ensure that kwargs from remote requests are overridden + by signing policies as is done for regular ones + """ + cert_args["subject"] = "CN=from_call" + cert_args["signing_policy"] = "testnosubjectpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) + + +def test_get_signing_policy_remote(x509_salt_ssh_cli, cert_args, ca_minion_config): + testpolicy = copy.deepcopy( + ca_minion_config["x509_signing_policies"]["testencpolicy"] + ) + testpolicy.pop("signing_private_key", None) + testpolicy.pop("signing_private_key_passphrase", None) + ret = x509_salt_ssh_cli.run( + "x509.get_signing_policy", "testencpolicy", ca_server=cert_args["ca_server"] + ) + assert ret.returncode == 0 + assert ret.data + assert ret.data == testpolicy + + +def test_get_signing_policy_remote_deprecated_name( + x509_salt_ssh_cli, cert_args, ca_minion_config +): + ret = x509_salt_ssh_cli.run( + "x509.get_signing_policy", + "testdeprecatednamepolicy", + ca_server=cert_args["ca_server"], + ) + assert ret.returncode == 0 + assert ret.data + assert "commonName" not in ret.data + assert "CN" in ret.data + assert ret.data["CN"] == "deprecated" + + +def test_get_signing_policy_remote_deprecated_ext( + x509_salt_ssh_cli, cert_args, ca_minion_config +): + ret = x509_salt_ssh_cli.run( + "x509.get_signing_policy", + "testdeprecatedextpolicy", + ca_server=cert_args["ca_server"], + ) + assert ret.returncode == 0 + assert ret.data + assert "X509v3 Basic Constraints" not in ret.data + assert "basicConstraints" in ret.data + assert ret.data["basicConstraints"] == "critical CA:FALSE" + + +def test_sign_remote_certificate_ext_override( + x509_salt_ssh_cli, cert_args, cert_args_exts +): + cert_args.update(cert_args_exts) + cert_args["signing_policy"] = "testextpolicy" + ret = x509_salt_ssh_cli.run("x509.create_certificate", **cert_args) + assert ret.returncode == 0 + assert ret.data + cert = _get_cert(ret.data) + assert ( + cert.extensions.get_extension_for_class(cx509.BasicConstraints).value.ca + is False + ) + assert ( + cert.extensions.get_extension_for_class(cx509.KeyUsage).value.digital_signature + is False + ) + with pytest.raises(cx509.ExtensionNotFound): + cert.extensions.get_extension_for_class(cx509.ExtendedKeyUsage) + assert ( + cert.extensions.get_extension_for_class( + cx509.IssuerAlternativeName + ).value.get_values_for_type(cx509.DNSName)[0] + == "salt.ca" + ) + assert ( + cert.extensions.get_extension_for_class( + cx509.SubjectAlternativeName + ).value.get_values_for_type(cx509.DNSName)[0] + == "sub.salt.ca" + ) + + +def _belongs_to(cert_or_pubkey, privkey): + if isinstance(cert_or_pubkey, cx509.Certificate): + cert_or_pubkey = cert_or_pubkey.public_key() + return x509util.is_pair(cert_or_pubkey, x509util.load_privkey(privkey)) + + +def _signed_by(cert, privkey): + return x509util.verify_signature(cert, x509util.load_privkey(privkey).public_key()) + + +def _get_cert(cert, encoding="pem", passphrase=None): + try: + p = Path(cert) + if p.exists(): + cert = p.read_bytes() + except Exception: # pylint: disable=broad-except + pass + + if encoding == "pem": + if not isinstance(cert, bytes): + cert = cert.encode() + return cx509.load_pem_x509_certificate(cert) + if encoding == "der": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + return cx509.load_der_x509_certificate(cert) + if encoding == "pkcs7_pem": + if not isinstance(cert, bytes): + cert = cert.encode() + return pkcs7.load_pem_pkcs7_certificates(cert) + if encoding == "pkcs7_der": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + return pkcs7.load_der_pkcs7_certificates(cert) + if encoding == "pkcs12": + if not isinstance(cert, bytes): + cert = base64.b64decode(cert) + if passphrase is not None and not isinstance(passphrase, bytes): + passphrase = passphrase.encode() + return pkcs12.load_pkcs12(cert, passphrase)