Skip to content

Commit 8cabca1

Browse files
committed
Account for PKCS#7 being unordered
1 parent 1897432 commit 8cabca1

4 files changed

Lines changed: 177 additions & 13 deletions

File tree

changelog/69893.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed stateful management of PKCS#7 certificates with appended chain using `x509_v2.certificate_managed`. Also fixed loading of PKCS#7-encoded certificate bundles with `salt.utils.x509.load_cert`.

salt/states/x509_v2.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@
187187
import os.path
188188
from datetime import datetime, timedelta, timezone
189189

190+
import salt.utils.dictupdate
190191
import salt.utils.files
191192
import salt.utils.platform
193+
import salt.utils.stringutils
194+
import salt.utils.versions
192195
from salt.exceptions import CommandExecutionError, SaltInvocationError
193196
from salt.state import STATE_INTERNAL_KEYWORDS as _STATE_INTERNAL_KEYWORDS
194197

@@ -505,7 +508,9 @@ def certificate_managed(
505508

506509
current_chain = current_chain or []
507510
ca_chain = [x509util.load_cert(x) for x in append_certs]
508-
if not _compare_ca_chain(current_chain, ca_chain):
511+
if not _compare_ca_chain(
512+
current_chain, ca_chain, unordered="pkcs7" in current_encoding
513+
):
509514
changes["additional_certs"] = True
510515

511516
(
@@ -1747,9 +1752,13 @@ def getextname(ext):
17471752
return {"added": added, "changed": changed, "removed": removed}
17481753

17491754

1750-
def _compare_ca_chain(current, new):
1751-
if not len(current) == len(new):
1755+
def _compare_ca_chain(current, new, unordered=False):
1756+
if len(current) != len(new):
17521757
return False
1758+
if unordered:
1759+
return {cert.fingerprint(hashes.SHA256()) for cert in new} == {
1760+
cert.fingerprint(hashes.SHA256()) for cert in current
1761+
}
17531762
for i, new_cert in enumerate(new):
17541763
if new_cert.fingerprint(hashes.SHA256()) != current[i].fingerprint(
17551764
hashes.SHA256()

salt/utils/x509.py

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,98 @@ def load_pubkey(pk, get_encoding=False):
869869
raise PubDeserializationError("Could not load DER-encoded public key.") from err
870870

871871

872+
def order_certs_naively(bundle, allow_orphans=True, require_leaf=True):
873+
"""
874+
Deterministically order certificates in a bundle using a naive algorithm.
875+
This is not a chain building algorithm! It just selects the longest chain
876+
of direct certification, preferring leaves by default, and appends all
877+
orphans ordered by their fingerprints, if orphans are allowed.
878+
879+
bundle
880+
A set of cryptography.x509.Certificate objects to order.
881+
882+
allow_orphans
883+
Do not require all certificates to build a single chain. Defaults to true.
884+
885+
require_leaf
886+
Require that a path begins with a certificate that itself has not
887+
been used to issue another certificate in the bundle. Defaults to true.
888+
"""
889+
if len(bundle) < 2:
890+
return list(bundle)
891+
892+
def _directly_issued_by(subject, issuer):
893+
if subject.issuer != issuer.subject:
894+
return False
895+
try:
896+
subject.verify_directly_issued_by(issuer)
897+
except (InvalidSignature, TypeError, ValueError):
898+
return False
899+
return True
900+
901+
def _fp(cert):
902+
return cert.fingerprint(hashes.SHA256())
903+
904+
ordered_bundle = tuple(sorted(bundle, key=_fp))
905+
issuers = {
906+
cert: [
907+
candidate
908+
for candidate in ordered_bundle
909+
if _directly_issued_by(cert, candidate)
910+
]
911+
for cert in ordered_bundle
912+
}
913+
if require_leaf:
914+
# ensure we treat self-signed root certificates that have not issued another certificate in this bundle as a leaf
915+
cert_issuers = {
916+
issuer
917+
for subject, candidates in issuers.items()
918+
for issuer in candidates
919+
if issuer != subject
920+
}
921+
leaves = {cert for cert in ordered_bundle if cert not in cert_issuers}
922+
if not leaves:
923+
# This would be unusual, but possible when e.g. two certificates signed each other
924+
raise ValueError(
925+
"Certificate bundle did not contain a single leaf certificate"
926+
)
927+
else:
928+
leaves = {}
929+
930+
def _paths_from(
931+
cert,
932+
seen,
933+
):
934+
candidates = [issuer for issuer in issuers[cert] if issuer not in seen]
935+
if not candidates:
936+
return [[cert]]
937+
return [
938+
[cert, *tail]
939+
for issuer in candidates
940+
for tail in _paths_from(issuer, seen | {issuer})
941+
]
942+
943+
paths = [
944+
path for cert in ordered_bundle for path in _paths_from(cert, frozenset({cert}))
945+
]
946+
947+
# Longest path first; fingerprints provide a stable tie-breaker.
948+
selected = min(
949+
paths,
950+
key=lambda path: (
951+
-int(path[0] in leaves),
952+
-len(path),
953+
tuple(_fp(cert) for cert in path),
954+
),
955+
)
956+
orphans = [cert for cert in ordered_bundle if cert not in selected]
957+
if not allow_orphans and orphans:
958+
raise ValueError(
959+
"Certificate bundle did not contain a singular chain comprising all certificates"
960+
)
961+
return [*selected, *orphans]
962+
963+
872964
def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False):
873965
"""
874966
Return a certificate instance from
@@ -910,12 +1002,13 @@ def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False):
9101002
) from err
9111003
else:
9121004
try:
913-
loaded = pkcs7.load_pem_pkcs7_certificates(pems[0])
1005+
chain = order_certs_naively(pkcs7.load_pem_pkcs7_certificates(pems[0]))
1006+
loaded = chain.pop(0) # the first cert is sure to be a leaf
9141007
if load_chain:
915-
return loaded.pop(0), loaded
1008+
return loaded, chain
9161009
if get_encoding:
917-
return loaded.pop(0), "pkcs7_pem", loaded, None
918-
return loaded.pop(0)
1010+
return loaded, "pkcs7_pem", chain, None
1011+
return loaded
9191012
except ValueError as err:
9201013
raise CertDeserializationError(
9211014
"Could not load PEM-encoded PKCS#7 blob"
@@ -952,14 +1045,20 @@ def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False):
9521045
# PKCS7
9531046
try:
9541047
# v37+
955-
loaded = pkcs7.load_der_pkcs7_certificates(cert)
956-
if load_chain:
957-
return loaded.pop(0), loaded
958-
if get_encoding:
959-
return loaded.pop(0), "pkcs7_der", loaded, None
960-
return loaded[0]
1048+
bundle = pkcs7.load_der_pkcs7_certificates(cert)
9611049
except ValueError:
9621050
pass
1051+
else:
1052+
try:
1053+
chain = order_certs_naively(bundle)
1054+
except ValueError as err:
1055+
raise CertDeserializationError(str(err)) from err
1056+
loaded = chain.pop(0) # the first cert is sure to be a leaf
1057+
if load_chain:
1058+
return loaded, chain
1059+
if get_encoding:
1060+
return loaded, "pkcs7_der", chain, None
1061+
return loaded
9631062
# nothing worked
9641063
raise CertDeserializationError(
9651064
"Could not deserialize binary data, neither as DER nor PKCS#7, PKCS#12."

tests/pytests/functional/utils/test_x509.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,3 +777,58 @@ def test_load_cert_pkcs7_orders_chain_with_multiple_valid_paths(
777777
assert chain[1].subject.rfc4514_string() == chain[0].issuer.rfc4514_string()
778778
orphan_subjects = {orphan.subject.rfc4514_string() for orphan in chain[2:]}
779779
assert orphan_subjects == {"CN=Root A", "CN=Intermediate"}
780+
781+
782+
@pytest.mark.parametrize(
783+
"certs,order",
784+
(
785+
(["leaf"], ["leaf"]),
786+
(["ca_A"], ["ca_A"]),
787+
(["leaf", "ca_AI", "ca_A"], ["leaf", "ca_AI", "ca_A"]),
788+
(["leaf", "ca_BI", "ca_B"], ["leaf", "ca_BI", "ca_B"]),
789+
(
790+
["ca_A", "ca_BI", "ca_B", "ca_AI", "leaf"],
791+
["leaf", "ca_BI", "ca_B", "ca_A", "ca_AI"],
792+
),
793+
(["ca_A", "ca_B", "ca_C"], ["ca_C", "ca_B", "ca_A"]),
794+
(
795+
["ca_A", "ca_B", "ca_C", "leaf", "ca_AI", "ca_BI", "ca_CI"],
796+
["leaf", "ca_BI", "ca_B", "ca_C", "ca_CI", "ca_A", "ca_AI"],
797+
),
798+
),
799+
)
800+
def test_order_certs_naively_works(certs, order, request):
801+
bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in certs]
802+
ordered_bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in order]
803+
res = x509.order_certs_naively(bundle)
804+
assert res == ordered_bundle
805+
806+
807+
@pytest.mark.parametrize(
808+
"certs,expected",
809+
(
810+
(["leaf"], ["leaf"]),
811+
(["ca_A"], ["ca_A"]),
812+
(["leaf", "ca_AI", "ca_A"], ["leaf", "ca_AI", "ca_A"]),
813+
(["leaf", "ca_BI", "ca_B"], ["leaf", "ca_BI", "ca_B"]),
814+
(["leaf", "ca_BI", "ca_B", "ca_AI"], False),
815+
(
816+
["ca_A", "ca_BI", "ca_B", "ca_AI", "leaf"],
817+
False,
818+
),
819+
(["ca_A", "ca_B", "ca_C"], False),
820+
),
821+
)
822+
def test_order_certs_naively_no_allow_orphans(certs, expected, request):
823+
if expected is False:
824+
ctx = pytest.raises(ValueError, match=".*did not contain a singular chain.*")
825+
ordered_bundle = []
826+
else:
827+
ordered_bundle = [
828+
x509.load_cert(request.getfixturevalue(cert)) for cert in expected
829+
]
830+
ctx = contextlib.nullcontext()
831+
bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in certs]
832+
with ctx:
833+
res = x509.order_certs_naively(bundle, allow_orphans=False)
834+
assert res == ordered_bundle

0 commit comments

Comments
 (0)