Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog.d/4159.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
- **A start now refuses a CRL that the CA it names by key identifier proves badly signed, even
beside a CA of the same name that has no subject key identifier.** Before, that second CA could
rescue the CRL: where its key verified the CRL, the start loaded the file with no warning, and
where the engine could not check its key, the start loaded the file with a WARNING. Now such a
CA counts for nothing once the CRL names another listed CA's key, and the running-hop reload
holds such a file back too. A CA the CRL names that cannot be checked still warns, and so does a
lone CA with no subject key identifier that cannot be checked. An RSA signature over SHA-1 that
a CA counted here made still reads as one the engine cannot check, as before: a start warns,
and the reload holds it back until a restart. That trades revocation freshness on a host that
accepts SHA-1 against applying live a CRL that a host refusing SHA-1 would fail every handshake
on; `docs/CONFIGURATION.md`, in the bullet on a signature the engine cannot check, states the
trade-off. The premise, that the engine's verifying
contexts use strict verification, and the cases where the start can refuse a CRL a handshake
could pass are stated in `docs/CONFIGURATION.md`, in the paragraph that starts "Why a CA with no
subject key identifier counts for nothing there". (vault BACKLOG #4159)
54 changes: 47 additions & 7 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2001,10 +2001,29 @@ otherwise (the first bullet below says what that leaves out). The CRL names that
key identifier. The engine ran the signature check with that key, and the check failed. Where
several listed CAs share that
name, the check must run and fail for each of them that the engine finds to fit the CRL's
authority key identifier. A handshake that OpenSSL judges by such a CRL fails with
`CRL signature failure`, an error that does not name the file. The refusal names the setting, the
file and the CRL. Give the setting the CRL that CA signed. No setting turns this check off, as for
an expired CRL.
authority key identifier and that carries the subject key identifier the CRL names. A fitting CA
with no subject key identifier counts for nothing then, whether its key verifies the CRL, does
not, or cannot be checked (vault BACKLOG #4159). A handshake whose chain runs through the named CA
and that OpenSSL judges by such a CRL fails with `CRL signature failure`, an error that does not
name the file. The refusal names the setting, the file and the CRL. Give the setting the CRL that
CA signed. No setting turns this check off, as for an expired CRL.

**Why a CA with no subject key identifier counts for nothing there, and where that refuses a CRL a
handshake could pass.** This paragraph is the one statement of that premise and its residuals;
the code and the changelog point here. The verifying contexts the engine builds set OpenSSL's
strict verification, as read from their call sites and measured for at least the outbound one.
Under it OpenSSL 3 refuses a verified chain of two or more certificates through a CA with no
subject key identifier, so such a CA cannot make the CRL valid for that chain. At least two cases
are left where the start refuses a CRL that a handshake could pass:

- OpenSSL skips those strict checks where the chain it verifies is one certificate long. That is
the length of the chain it builds, which ends at a listed CA, not the number of certificates the
peer sends. So a peer whose own certificate is such a CA, self-signed and listed for the hop,
passes. It was measured, on CPython 3.14.7 with OpenSSL 3.5.7, to pass a strict handshake judged
by a CRL that CA signed and that names the key of another listed CA of the same name, a CRL the
start refuses.
- An OpenSSL build or a verifying context without the strict check could take such a CA for the
CRL's issuer. The engine sets no OpenSSL version floor.

The check reads only the file a CRL setting names. A CRL can also reach a hop through its CA file
alone, on at least the hops that the paragraph on a CRL inside a CA file, further down, names. The
Expand Down Expand Up @@ -2035,9 +2054,30 @@ three cases warn:
- A listed CA has the CRL's issuer name and does not verify it, but the CRL names no key. A CA that
renewed its key can read this way while handshakes still pass. That was measured for one chain
shape, where the peer sends the renewed CA certified by the old key.
- The engine cannot check the signature with the listed CA's key, even where the CRL names that key.
Two measured examples are an ECDSA signature on a CRL that names an RSA key, and an RSA signature
over SHA-1 that the CA did make.
- The engine cannot check the signature with a fitting listed CA's key, at least where it does
not support that algorithm or that pairing of key and signature, or where reading the key or
checking with it raises an error. Where the CRL names the key of a listed CA that fits it, only
such a CA counts here (the paragraph that starts "Why a CA with no subject key identifier" says
why). Where it names no such CA's key, as when it names a key no listed CA carries, every
fitting CA counts, one with no subject key identifier included. Two measured examples are an
ECDSA signature on a CRL that names an RSA key, and an RSA signature over SHA-1 that the CA did
make. The CA's key accepts that second one, and the `cryptography` library's own check refuses
its digest, so the engine reads it as one it cannot check.

**That SHA-1 case is a trade-off (vault BACKLOG #4159).** A start loads such a CRL with the
WARNING, and the running-hop reload holds it back, so it is applied at the next restart. The
cost falls on a host whose TLS library accepts SHA-1, as OpenSSL 3.5.7 was measured to, judging
a peer by such a CRL. There a revocation in a new copy waits for a restart, and if nobody
restarts, the copy the hop holds lapses at its next update and the hop then refuses the peers
it judges by it, as for any held-back file. What it avoids is applying live, with nobody acting, a CRL on which a
host that refuses SHA-1, by its build or its system policy, would fail every handshake through
that CA. It does not prevent that failure: on such a host the restart loads the CRL with the
WARNING, and the handshakes through that CA then fail. Where the copy the hop holds is itself an
earlier SHA-1 CRL from that CA, such a host fails those handshakes already, and holding the new
copy back gains nothing there. The engine checks with the
`cryptography` library's OpenSSL and the handshake runs on Python's, so it cannot tell which
kind of host it is on. A forged SHA-1 signature that names a listed CA's key is still proved
bad, and refuses the file.

If the check itself fails, the engine logs an ERROR that says so. It loads the file unless a CRL
it judged before the failure is proved badly signed, which refuses the file. Where it loads, the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,10 @@ hop's own CA list. It refuses one case only: the CRL names a listed CA's key by
engine ran the signature check with that key, and the check failed (corrected under vault BACKLOG
#3506; it read "that key did not sign it", which is also true of a signature of another kind than
the key, and that loads). Anything short of that proof loads with a WARNING, at least an issuer the
hop does not list, a CRL that names no key, and a key the engine cannot check with. `docs/CONFIGURATION.md`,
hop does not list, a CRL that names no key, and a key the engine cannot check with. Since vault
BACKLOG #4159, that last case loads only where the unchecked key is the one the CRL names, or the
CRL names no listed key: a same-name CA with no subject key identifier no longer stops the
refusal, whether its key verifies the CRL or cannot be checked. `docs/CONFIGURATION.md`,
under "CRL files", is the one place that says how often it is logged and what a refusal stops.
So the reload refuses more than a start does, on purpose. The ruling's reason is a CA that
renewed its key: its CRL can fail the engine's check while handshakes still pass, because OpenSSL
Expand Down
19 changes: 13 additions & 6 deletions messagefoundry/config/tls_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,11 @@ def _refuse_crl_count(loaded: int, blocks: Sequence[CrlBlock], *, label: str) ->
"verify the CRL, but the CRL does not "
"name that CA's key, so a renewed CA key could explain it"
),
"uncheckable": "the engine cannot check its signature with the listed CA's key",
"uncheckable": (
"the engine cannot check its signature with the key of a listed CA that fits it and whose "
"key the CRL names by key identifier or, where it names no such CA's key, with the key of "
"some listed CA that fits it"
),
}
#: How many CRLs that warning describes before it only counts the rest.
_UNCONFIRMED_SHOWN = 3
Expand All @@ -590,8 +594,10 @@ def _check_crl_signatures(

**Everything short of proof loads, with one warning.** That covers at least a CRL that names
no key, a key the engine cannot check with, and a CRL the engine matches to no listed CA. The
last is the usual case in a two-tier PKI, where the peer sends the intermediate that issued
the CRL, so it must not log per hop. The warning is one line for the file, and
key is the one the CRL names or, where it names no listed CA's key, that of any listed CA
that fits it, one with no Subject Key Identifier included. A CRL matched to no listed CA is
the usual case in a two-tier PKI, where the peer sends the intermediate that issued the CRL,
so it must not log per hop. The warning is one line for the file, and
:func:`~messagefoundry.config.loaded_crls.first_unconfirmed_signature` decides how often it is
logged. That function is asked only when there is a line to log. So it holds the last file
version that logged one, and an earlier version is logged again only when it returns after
Expand Down Expand Up @@ -641,9 +647,10 @@ def _check_crl_signatures(
if (bad := next((s for s in judged if s.state == "bad"), None)) is not None:
raise ValueError(
f"{label}: {bad.where} names the key of a CA certificate this hop trusts, and that "
"key does not verify the CRL's signature. A handshake OpenSSL judges by this CRL "
"fails with 'CRL signature failure'. Give this setting the CRL that CA signed, or "
"remove this CRL if a good one from that issuer is already in the file"
"key does not verify the CRL's signature. A handshake whose chain runs through that "
"CA and that OpenSSL judges by this CRL fails with 'CRL signature failure'. Give this "
"setting the CRL that CA signed, or remove this CRL if a good one from that issuer is "
"already in the file"
)
unconfirmed = [signature for signature in judged if signature.state != "verified"]
finding = tuple(signature.state for signature in judged)
Expand Down
6 changes: 3 additions & 3 deletions messagefoundry/pipeline/cert_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,10 +770,10 @@ def _ca_file_crl_refusal(
# (tls_policy._check_crl_signatures). OpenSSL can prefer a newer good CRL from the issuer.
# The fix and its claim are one sentence, short enough for the alert list's cut (#3512).
return (
"whose CA file now holds a CRL its issuer's key does not verify",
"whose CA file now holds a CRL that the CA key it names does not verify",
"Replace the CRL in the CA file with one its CA signed before restarting: a handshake "
"OpenSSL judges by it fails. If a good CRL from that issuer is already in the file, "
"remove this one instead",
"OpenSSL judges by it through the CA it names fails. If a good CRL from that issuer "
"is already in the file, remove this one instead",
)
if states - _PASSING - _UNCONFIRMED:
# A state this scan does not name, beside any other: never a pass and never the weaker
Expand Down
7 changes: 4 additions & 3 deletions messagefoundry/pipeline/crl_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@
"refuses the file too only where it proves the signature bad. Short of that proof the "
"signature does not stop a start: unless another rule refuses the file, a start loads it "
"with a warning, and each handshake then decides. That covers at least a CRL that names no "
"key and a signature the engine cannot check. The handshake passes where the peer's chain "
"carries the CA that signed the CRL, as after a CA renews its key, and fails where it does "
"not"
"key and a signature the engine cannot check with the key the CRL names, or, where it names "
"no listed CA's key, with the key of some listed CA that fits it. The handshake passes where "
"the peer's chain carries the CA that signed the CRL, as after a CA renews its key, and fails "
"where it does not"
)
NO_ISSUER = (
"Restart the engine to apply the file. The reload checks a CRL's signature against the hop's "
Expand Down
Loading
Loading