diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8eff4292f..474785223 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,7 +18,7 @@ env: # determine whether this pull request has permissions to push to registry, or artifacts # should be used to transfer images between jobs. Forked and dependabot builds don't # have permission to push to registry. - use_registry: ${{ ! (github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/'))) }} + use_registry: true jobs: # builds all docker images in parallel diff --git a/Changelog.md b/Changelog.md index f1a88084d..981959fd9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,9 +4,32 @@ _Compared to the latest 1.10 release._ -### Feature changes -- ... +### TLS updates for NCSC 2025 guidelines + +All tests were updated to match the +[2025-05 version of the NCSC TLS guidelines](https://www.ncsc.nl/documenten/publicaties/2025/juni/01/ict-beveiligingsrichtlijnen-voor-transport-layer-security-2025-05). +Most significant changes: + +- The requirements on TLS versions, TLS authentication, curves, hashes, key exchange algorithms, FFDHE groups, + RSA key lengths, and bulk encryption algorithms were updated to match the new guidelines. +- A test for RSA PKCS#1 v1.5 was added (only PSS padding is sufficient). +- A test for Extended Master Secret (RFC7627) was added. +- Client-initiated renegotiation is now acceptable, if limited to less than 10. +- All checks on certificates apply only to the TODO TODO certificates. + +### Other TLS updates + +- Certificates that do not have OCSP enabled, which means stapling is not possible, + [are now detected as such](https://github.com/internetstandards/Internet.nl/issues/1641). + Several issues with OCSP stapling reliability were also resolved. +- Issues were fixed where the cipher order failed to detect some bad scenarios, + including some where servers preferred RSA over ECDHE, or CBC over POLY1305. +- CCM_8 ciphers are now detected when enabled on a server. +- OLD ciphers are no longer detected. +- The cipher order test no longer separates between "the server cipher order preference is wrong" + and "the server has no preference". + ### Significant internal changes @@ -18,7 +41,18 @@ _Compared to the latest 1.10 release._ ### API changes -- ... +This release has API version 2.7.0. + +The changes noted above are reflected in the API as well, e.g. which ciphers +are considered bad, which are listed in the API output, along with score impacts. +Additionally, the API structure changes are: +- OCSP stapling has a new status `not_in_cert`, for when a certificate does not have OCSP enabled, + therefore stapling is neither required nor possible. +- The cipher order status no longer returns `not_prescribed` or `not_seclevel` for new tests. + The insufficient statuses are now `bad` for preferring phase out over good and/or sufficient; + and `sufficient_above_good` for preferring sufficient over good. +- `extended_master_secret_status` and `kex_rsa_pkcs` were added to the TLS details. +- `client_reneg` in the TLS details was changed from a boolean to a new enum. ## 1.10.6 diff --git a/checks/categories.py b/checks/categories.py index 809ddd697..63cf13433 100644 --- a/checks/categories.py +++ b/checks/categories.py @@ -3,6 +3,7 @@ from typing import Optional from checks import scoring +from checks.models import TLSClientInitiatedRenegotiationStatus, KexRSAPKCSStatus, TLSExtendedMasterSecretStatus from checks.scoring import ( ORDERED_STATUSES, STATUS_ERROR, @@ -184,6 +185,8 @@ def __init__(self, name="web-tls"): WebTlsZeroRTT, WebTlsOCSPStapling, WebTlsKexHashFunc, + WebTlsKexRSAPKCSStatus, + WebTLSExtendedMasterSecret, # WebTlsDaneRollover, ] super().__init__(name, subtests) @@ -256,6 +259,8 @@ def __init__(self, name="mail-tls"): MailTlsDaneRollover, MailTlsZeroRTT, MailTlsKexHashFunc, + MailTlsKexRSAPKCSStatus, + MailTLSExtendedMasterSecret, # MailTlsOCSPStapling, # Disabled for mail. ] super().__init__(name, subtests) @@ -1095,6 +1100,11 @@ def result_na(self): self.verdict = "detail web tls cipher-order verdict na" self.tech_data = "" + def result_sufficient_above_good(self): + self._status(STATUS_INFO) + self.verdict = "detail web tls cipher-order verdict sufficient-above-good" + self.tech_data = "" + class WebTlsVersion(Subtest): def __init__(self): @@ -1183,14 +1193,27 @@ def __init__(self): model_score_field="client_reneg_score", ) - def result_good(self): + def save_result(self, status: TLSClientInitiatedRenegotiationStatus): + handlers = { + TLSClientInitiatedRenegotiationStatus.not_allowed: self.result_not_allowed, + TLSClientInitiatedRenegotiationStatus.allowed_with_low_limit: self.result_allowed_with_low_limit, + TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit: self.result_allowed_with_too_high_limit, + } + return handlers[status]() + + def result_not_allowed(self): self._status(STATUS_SUCCESS) - self.verdict = "detail web tls renegotiation-client verdict good" + self.verdict = "detail web tls renegotiation-client verdict not-allowed" self.tech_data = "detail tech data no" - def result_bad(self): + def result_allowed_with_low_limit(self): + self._status(STATUS_INFO) + self.verdict = "detail web tls renegotiation-client verdict allowed-with-low-limit" + self.tech_data = "detail tech data phase-out" + + def result_allowed_with_too_high_limit(self): self._status(STATUS_FAIL) - self.verdict = "detail web tls renegotiation-client verdict bad" + self.verdict = "detail web tls renegotiation-client verdict allowed-with-too-high-limit" self.tech_data = "detail tech data yes" @@ -1488,18 +1511,101 @@ def __init__(self): def result_good(self): self._status(STATUS_SUCCESS) self.verdict = "detail web tls kex-hash-func verdict good" - self.tech_data = "detail tech data yes" + self.tech_data = "detail tech data good" def result_bad(self): self._status(STATUS_FAIL) - self.verdict = "detail web tls kex-hash-func verdict phase-out" - self.tech_data = "detail tech data no" + self.verdict = "detail web tls kex-hash-func verdict bad" + self.tech_data = "detail tech data insufficient" def result_unknown(self): self._status(STATUS_INFO) self.verdict = "detail web tls kex-hash-func verdict other" self.tech_data = "detail tech data not-applicable" + def result_phase_out(self): + self._status(STATUS_NOTICE) + self.verdict = "detail web tls kex-hash-func verdict phase-out" + self.tech_data = "detail tech data phase-out" + + +class WebTlsKexRSAPKCSStatus(Subtest): + def __init__(self): + super().__init__( + name="key_exchange_rsa_pkcs", + label="detail web tls key-exchange-rsa-pkcs label", + explanation="detail web tls key-exchange-rsa-pkcs exp", + tech_string="detail web tls key-exchange-rsa-pkcs tech table", + worst_status=scoring.TLS_KEX_RSA_PKCS_WORST_STATUS, + full_score=scoring.TLS_KEX_RSA_PKCS_GOOD, + model_score_field="key_exchange_rsa_pkcs_score", + ) + + def save_result(self, status: KexRSAPKCSStatus): + handlers = { + KexRSAPKCSStatus.good: self.result_good, + KexRSAPKCSStatus.bad: self.result_bad, + KexRSAPKCSStatus.unknown: self.result_unknown, + } + return handlers[status]() + + def result_good(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail web tls key-exchange-rsa-pkcs verdict good" + self.tech_data = "detail tech data good" + + def result_bad(self): + self._status(STATUS_FAIL) + self.verdict = "detail web tls key-exchange-rsa-pkcs verdict bad" + self.tech_data = "detail tech data insufficient" + + def result_unknown(self): + self._status(STATUS_INFO) + self.verdict = "detail web tls key-exchange-rsa-pkcs verdict other" + self.tech_data = "detail tech data not-applicable" + + +class WebTLSExtendedMasterSecret(Subtest): + def __init__(self): + super().__init__( + name="extended_master_secret", + label="detail web tls extended-master-secret label", + explanation="detail web tls extended-master-secret exp", + tech_string="detail web tls extended-master-secret tech table", + worst_status=scoring.TLS_EXTENDED_MASTER_SECRET_WORST_STATUS, + full_score=scoring.TLS_EXTENDED_MASTER_SECRET_GOOD, + model_score_field="extended_master_secret_score", + ) + + def save_result(self, status: TLSExtendedMasterSecretStatus): + handlers = { + TLSExtendedMasterSecretStatus.supported: self.result_good, + TLSExtendedMasterSecretStatus.na_no_tls_1_2: self.result_na_no_tls_1_2, + TLSExtendedMasterSecretStatus.not_supported: self.result_bad, + TLSExtendedMasterSecretStatus.unknown: self.result_unknown, + } + return handlers[status]() + + def result_good(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail web tls extended-master-secret verdict good" + self.tech_data = "detail tech data good" + + def result_bad(self): + self._status(STATUS_FAIL) + self.verdict = "detail web tls extended-master-secret verdict bad" + self.tech_data = "detail tech data insufficient" + + def result_unknown(self): + self._status(STATUS_INFO) + self.verdict = "detail web tls extended-master-secret verdict unknown" + self.tech_data = "detail tech data not-applicable" + + def result_na_no_tls_1_2(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail web tls extended-master-secret verdict na-no-tls-1-2" + self.tech_data = "detail tech data phase-out" + class MailTlsStarttlsExists(Subtest): def __init__(self): @@ -1676,6 +1782,11 @@ def result_na(self): self.verdict = "detail mail tls cipher-order verdict na" self.tech_data = "" + def result_sufficient_above_good(self): + self._status(STATUS_INFO) + self.verdict = "detail web tls cipher-order verdict sufficient-above-good" + self.tech_data = "" + class MailTlsVersion(Subtest): def __init__(self): @@ -1780,19 +1891,27 @@ def __init__(self): model_score_field="client_reneg_score", ) - def was_tested(self): - self.worst_status = scoring.MAIL_TLS_CLIENT_RENEG_WORST_STATUS + def save_result(self, status: TLSClientInitiatedRenegotiationStatus): + handlers = { + TLSClientInitiatedRenegotiationStatus.not_allowed: self.result_not_allowed, + TLSClientInitiatedRenegotiationStatus.allowed_with_low_limit: self.result_allowed_with_low_limit, + TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit: self.result_allowed_with_too_high_limit, + } + return handlers[status]() - def result_good(self): - self.was_tested() + def result_not_allowed(self): self._status(STATUS_SUCCESS) - self.verdict = "detail mail tls renegotiation-client verdict good" + self.verdict = "detail mail tls renegotiation-client verdict not-allowed" self.tech_data = "detail tech data no" - def result_bad(self): - self.was_tested() + def result_allowed_with_low_limit(self): + self._status(STATUS_INFO) + self.verdict = "detail mail tls renegotiation-client verdict allowed-with-low-limit" + self.tech_data = "detail tech data phase-out" + + def result_allowed_with_too_high_limit(self): self._status(STATUS_FAIL) - self.verdict = "detail mail tls renegotiation-client verdict bad" + self.verdict = "detail mail tls renegotiation-client verdict allowed-with-too-high-limit" self.tech_data = "detail tech data yes" @@ -2069,10 +2188,93 @@ def result_bad(self): def result_unknown(self): self.was_tested() self._status(STATUS_INFO) - self.verdict = "detail mail tls kex-hash-func verdict other" + self.verdict = "detail mail tls kex-hash-func verdict unknown" + self.tech_data = "detail tech data not-applicable" + + def result_phase_out(self): + self._status(STATUS_NOTICE) + self.verdict = "detail web tls kex-hash-func verdict phase-out" + self.tech_data = "detail tech data phase-out" + + +class MailTlsKexRSAPKCSStatus(Subtest): + def __init__(self): + super().__init__( + name="key_exchange_rsa_pkcs", + label="detail mail tls key-exchange-rsa-pkcs label", + explanation="detail mail tls key-exchange-rsa-pkcs exp", + tech_string="detail mail tls key-exchange-rsa-pkcs tech table", + worst_status=scoring.TLS_KEX_RSA_PKCS_WORST_STATUS, + full_score=scoring.TLS_KEX_RSA_PKCS_GOOD, + model_score_field="key_exchange_rsa_pkcs_score", + ) + + def save_result(self, status: KexRSAPKCSStatus): + handlers = { + KexRSAPKCSStatus.good: self.result_good, + KexRSAPKCSStatus.bad: self.result_bad, + KexRSAPKCSStatus.unknown: self.result_unknown, + } + return handlers[status]() + + def result_good(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail mail tls key-exchange-rsa-pkcs verdict good" + self.tech_data = "detail tech data good" + + def result_bad(self): + self._status(STATUS_FAIL) + self.verdict = "detail mail tls key-exchange-rsa-pkcs verdict bad" + self.tech_data = "detail tech data insufficient" + + def result_unknown(self): + self._status(STATUS_INFO) + self.verdict = "detail mail tls key-exchange-rsa-pkcs verdict unknown" self.tech_data = "detail tech data not-applicable" +class MailTLSExtendedMasterSecret(Subtest): + def __init__(self): + super().__init__( + name="extended_master_secret", + label="detail mail tls extended-master-secret label", + explanation="detail mail tls extended-master-secret exp", + tech_string="detail mail tls extended-master-secret tech table", + worst_status=scoring.TLS_EXTENDED_MASTER_SECRET_WORST_STATUS, + full_score=scoring.TLS_EXTENDED_MASTER_SECRET_GOOD, + model_score_field="extended_master_secret_score", + ) + + def save_result(self, status: TLSExtendedMasterSecretStatus): + handlers = { + TLSExtendedMasterSecretStatus.supported: self.result_good, + TLSExtendedMasterSecretStatus.na_no_tls_1_2: self.result_na_no_tls_1_2, + TLSExtendedMasterSecretStatus.not_supported: self.result_bad, + TLSExtendedMasterSecretStatus.unknown: self.result_unknown, + } + return handlers[status]() + + def result_good(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail mail tls extended-master-secret verdict good" + self.tech_data = "detail tech data good" + + def result_bad(self): + self._status(STATUS_FAIL) + self.verdict = "detail mail tls extended-master-secret verdict bad" + self.tech_data = "detail tech data insufficient" + + def result_unknown(self): + self._status(STATUS_INFO) + self.verdict = "detail mail tls extended-master-secret verdict unknown" + self.tech_data = "detail tech data not-applicable" + + def result_na_no_tls_1_2(self): + self._status(STATUS_SUCCESS) + self.verdict = "detail mail tls extended-master-secret verdict na-no-tls-1-2" + self.tech_data = "detail tech data phase-out" + + class MailTlsDaneExists(Subtest): def __init__(self): super().__init__( diff --git a/checks/migrations/0019_domaintesttls_client_reneg_to_enum.py b/checks/migrations/0019_domaintesttls_client_reneg_to_enum.py new file mode 100644 index 000000000..3ddfcb93c --- /dev/null +++ b/checks/migrations/0019_domaintesttls_client_reneg_to_enum.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.22 on 2025-07-22 18:17 +from enum import Enum + +import checks.models +from django.db import migrations +import enumfields.fields + + +class TLSClientInitiatedRenegotiationStatus(Enum): + not_allowed = 1 + allowed_with_low_limit = 2 + allowed_with_too_high_limit = 3 + + +def set_client_reneg_new(apps, schema_editor): + DomainTestTls = apps.get_model("checks", "DomainTestTls") + DomainTestTls.objects.update(client_reneg_new=TLSClientInitiatedRenegotiationStatus.not_allowed.value) + DomainTestTls.objects.filter(client_reneg=True).update( + client_reneg_new=TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit.value + ) + + +def set_client_reneg_old(apps, schema_editor): + DomainTestTls = apps.get_model("checks", "DomainTestTls") + DomainTestTls.objects.update(client_reneg=False) + DomainTestTls.objects.filter( + client_reneg_new=TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit.value + ).update(client_reneg=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("checks", "0018_domaintesttls_caa_records"), + ] + + operations = [ + migrations.AddField( + model_name="domaintesttls", + name="client_reneg_new", + field=enumfields.fields.EnumField( + enum=checks.models.TLSClientInitiatedRenegotiationStatus, + max_length=10, + null=True, + ), + ), + migrations.RunPython(set_client_reneg_new, set_client_reneg_old), + migrations.RemoveField( + model_name="domaintesttls", + name="client_reneg", + ), + migrations.RenameField( + model_name="domaintesttls", + old_name="client_reneg_new", + new_name="client_reneg", + ), + ] diff --git a/checks/migrations/0020_domaintesttls_extended_master_secret_and_more.py b/checks/migrations/0020_domaintesttls_extended_master_secret_and_more.py new file mode 100644 index 000000000..00ae7d9b5 --- /dev/null +++ b/checks/migrations/0020_domaintesttls_extended_master_secret_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.22 on 2025-08-12 12:28 + +import checks.models +from django.db import migrations, models +import enumfields.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("checks", "0019_domaintesttls_client_reneg_to_enum"), + ] + + operations = [ + migrations.AddField( + model_name="domaintesttls", + name="extended_master_secret", + field=enumfields.fields.EnumField( + default=3, + enum=checks.models.TLSExtendedMasterSecretStatus, + max_length=10, + ), + ), + migrations.AddField( + model_name="domaintesttls", + name="extended_master_secret_score", + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name="domaintesttls", + name="key_exchange_rsa_pkcs", + field=enumfields.fields.EnumField(default=2, enum=checks.models.KexRSAPKCSStatus, max_length=10), + ), + migrations.AddField( + model_name="domaintesttls", + name="key_exchange_rsa_pkcs_score", + field=models.IntegerField(null=True), + ), + ] diff --git a/checks/models.py b/checks/models.py index 71b62f391..9817a7a47 100644 --- a/checks/models.py +++ b/checks/models.py @@ -89,16 +89,29 @@ class OcspStatus(Enum): not_in_cert = 3 +class TLSClientInitiatedRenegotiationStatus(Enum): + not_allowed = 1 + allowed_with_low_limit = 2 + allowed_with_too_high_limit = 3 + + class ZeroRttStatus(Enum): bad = 0 good = 1 na = 2 +class KexRSAPKCSStatus(Enum): + bad = 0 + good = 1 + unknown = 2 + + class KexHashFuncStatus(Enum): bad = 0 good = 1 unknown = 2 + phase_out = 3 class CipherOrderStatus(Enum): @@ -107,6 +120,14 @@ class CipherOrderStatus(Enum): not_prescribed = 2 not_seclevel = 3 na = 4 # Don't care about order; only GOOD ciphers. + sufficient_above_good = 5 + + +class TLSExtendedMasterSecretStatus(Enum): + supported = 0 + not_supported = 1 + na_no_tls_1_2 = 2 + unknown = 3 def conn_test_id(): @@ -517,7 +538,7 @@ class DomainTestTls(BaseTestModel): compression_score = models.IntegerField(null=True) secure_reneg = models.BooleanField(null=True, default=False) secure_reneg_score = models.IntegerField(null=True) - client_reneg = models.BooleanField(null=True, default=False) + client_reneg = EnumField(TLSClientInitiatedRenegotiationStatus, null=True) client_reneg_score = models.IntegerField(null=True) zero_rtt = EnumField(ZeroRttStatus, default=ZeroRttStatus.bad) @@ -529,6 +550,12 @@ class DomainTestTls(BaseTestModel): kex_hash_func = EnumField(KexHashFuncStatus, default=KexHashFuncStatus.bad) kex_hash_func_score = models.IntegerField(null=True) + key_exchange_rsa_pkcs = EnumField(KexRSAPKCSStatus, default=KexRSAPKCSStatus.unknown) + key_exchange_rsa_pkcs_score = models.IntegerField(null=True) + + extended_master_secret = EnumField(TLSExtendedMasterSecretStatus, default=TLSExtendedMasterSecretStatus.unknown) + extended_master_secret_score = models.IntegerField(null=True) + forced_https = EnumField(ForcedHttpsStatus, default=ForcedHttpsStatus.bad) forced_https_score = models.IntegerField(null=True) @@ -607,6 +634,10 @@ def __dir__(self): "ocsp_stapling_score", "kex_hash_func", "kex_hash_func_score", + "key_exchange_rsa_pkcs", + "key_exchange_rsa_pkcs_score", + "extended_master_secret", + "extended_master_secret_score", "forced_https", "forced_https_score", "http_compression_enabled", @@ -648,10 +679,12 @@ def get_web_api_details(self): "protocols_phase_out": self.protocols_phase_out, "compression": self.compression, "secure_reneg": self.secure_reneg, - "client_reneg": self.client_reneg, + "client_reneg": self.client_reneg.name, "zero_rtt": self.zero_rtt.name, "ocsp_stapling": self.ocsp_stapling.name, "kex_hash_func": self.kex_hash_func.name, + "key_exchange_rsa_pkcs": self.key_exchange_rsa_pkcs.name, + "extended_master_secret": self.extended_master_secret.name, "https_redirect": self.forced_https.name, "http_compression": self.http_compression_enabled, "hsts": self.hsts_enabled, @@ -684,9 +717,11 @@ def get_mail_api_details(self): "protocols_phase_out": self.protocols_phase_out, "compression": self.compression, "secure_reneg": self.secure_reneg, - "client_reneg": self.client_reneg, + "client_reneg": self.client_reneg.name, "zero_rtt": self.zero_rtt.name, "kex_hash_func": self.kex_hash_func.name, + "key_exchange_rsa_pkcs": self.key_exchange_rsa_pkcs.name, + "extended_master_secret": self.extended_master_secret.name, "cert_chain": self.cert_chain, "cert_trusted": self.cert_trusted, "cert_pubkey_bad": self.cert_pubkey_bad, diff --git a/checks/scoring.py b/checks/scoring.py index f59ef6195..4ff9cc38c 100644 --- a/checks/scoring.py +++ b/checks/scoring.py @@ -144,6 +144,7 @@ WEB_TLS_SECURE_RENEG_WORST_STATUS = STATUS_FAIL WEB_TLS_CLIENT_RENEG_GOOD = FULL_WEIGHT_POINTS +WEB_TLS_CLIENT_RENEG_OK = FULL_WEIGHT_POINTS WEB_TLS_CLIENT_RENEG_BAD = NO_POINTS WEB_TLS_CLIENT_RENEG_WORST_STATUS = STATUS_INFO @@ -164,6 +165,11 @@ WEB_TLS_HOSTMATCH_BAD = NO_POINTS WEB_TLS_HOSTMATCH_WORST_STATUS = STATUS_FAIL +TLS_EXTENDED_MASTER_SECRET_GOOD = FULL_WEIGHT_POINTS +TLS_EXTENDED_MASTER_SECRET_OK = FULL_WEIGHT_POINTS +TLS_EXTENDED_MASTER_SECRET_BAD = NO_POINTS +TLS_EXTENDED_MASTER_SECRET_WORST_STATUS = STATUS_FAIL + CAA_GOOD = FULL_WEIGHT_POINTS CAA_BAD = NO_POINTS CAA_WORST_STATUS = STATUS_NOTICE @@ -190,6 +196,11 @@ WEB_TLS_OCSP_STAPLING_BAD = NO_POINTS WEB_TLS_OCSP_STAPLING_WORST_STATUS = STATUS_NOTICE +TLS_KEX_RSA_PKCS_GOOD = FULL_WEIGHT_POINTS +TLS_KEX_RSA_PKCS_OK = FULL_WEIGHT_POINTS +TLS_KEX_RSA_PKCS_BAD = NO_POINTS +TLS_KEX_RSA_PKCS_WORST_STATUS = STATUS_NOTICE + WEB_TLS_KEX_HASH_FUNC_GOOD = FULL_WEIGHT_POINTS WEB_TLS_KEX_HASH_FUNC_OK = FULL_WEIGHT_POINTS WEB_TLS_KEX_HASH_FUNC_BAD = NO_POINTS diff --git a/checks/tasks/tls/evaluation.py b/checks/tasks/tls/evaluation.py index b07715985..e7f744354 100644 --- a/checks/tasks/tls/evaluation.py +++ b/checks/tasks/tls/evaluation.py @@ -4,22 +4,35 @@ from cryptography.hazmat._oid import AuthorityInformationAccessOID, ExtensionOID from cryptography.x509 import AuthorityInformationAccess, ExtensionNotFound from nassl.ephemeral_key_info import EcDhEphemeralKeyInfo, DhEphemeralKeyInfo, OpenSslEvpPkeyEnum -from sslyze import TlsVersionEnum, CipherSuiteAcceptedByServer, CipherSuite, CertificateDeploymentAnalysisResult +from nassl.ssl_client import ExtendedMasterSecretSupportEnum +from sslyze import ( + TlsVersionEnum, + CipherSuiteAcceptedByServer, + CipherSuite, + CertificateDeploymentAnalysisResult, + SessionRenegotiationScanResult, +) +from sslyze.connection_helpers.tls_connection import SslConnection from sslyze.plugins.openssl_cipher_suites.cipher_suites import _TLS_1_3_CIPHER_SUITES from checks import scoring -from checks.models import KexHashFuncStatus, CipherOrderStatus, OcspStatus +from checks.models import ( + KexHashFuncStatus, + CipherOrderStatus, + OcspStatus, + KexRSAPKCSStatus, + TLSClientInitiatedRenegotiationStatus, + TLSExtendedMasterSecretStatus, +) +from checks.scoring import TLS_EXTENDED_MASTER_SECRET_GOOD, TLS_EXTENDED_MASTER_SECRET_BAD from checks.tasks.tls.tls_constants import ( PROTOCOLS_GOOD, PROTOCOLS_SUFFICIENT, PROTOCOLS_PHASE_OUT, - FS_ECDH_MIN_KEY_SIZE, FS_EC_PHASE_OUT, FS_EC_GOOD, - FS_DH_MIN_KEY_SIZE, FFDHE_GENERATOR, - FFDHE2048_PRIME, - FFDHE_SUFFICIENT_PRIMES, + FFDHE_PHASE_OUT_PRIMES, CIPHERS_GOOD, CIPHERS_SUFFICIENT, CIPHERS_PHASE_OUT, @@ -98,28 +111,23 @@ def from_ciphers_accepted(cls, ciphers_accepted: List[CipherSuiteAcceptedByServe phase_out = set() bad = set() - # Evaluate according to NCSC table 4 and table 10 + # Evaluate according to NCSC 3.3.2.1 table 3 and 3.3.3.1 table 7 for suite in _unique_unhashable(ciphers_accepted): key = suite.ephemeral_key if not key: continue if isinstance(key, EcDhEphemeralKeyInfo): - if key.size < FS_ECDH_MIN_KEY_SIZE: - bad.add(f"ECDH-{key.size}") if key.curve in FS_EC_PHASE_OUT: phase_out.add(f"ECDH-{key.curve_name}") elif key.curve not in FS_EC_GOOD: bad.add(f"ECDH-{key.curve_name}") if isinstance(key, DhEphemeralKeyInfo): - if key.size < FS_DH_MIN_KEY_SIZE: - bad.add(f"DH-{key.size}") - # NCSC table 10 if key.generator == FFDHE_GENERATOR: - if key.prime == FFDHE2048_PRIME: - phase_out.add("FFDHE-2048") - elif key.prime not in FFDHE_SUFFICIENT_PRIMES: + if key.prime in FFDHE_PHASE_OUT_PRIMES: + phase_out.add(f"DH-{key.size}") + else: bad.add(f"DH-{key.size}") dh_sizes = [ @@ -251,11 +259,73 @@ def score(self) -> scoring.Score: ) +@dataclass(frozen=True) +class TLSRenegotiationEvaluation: + """ + Evaluate the secure renegotiation settings per NCSC 3.4.2 + """ + + supports_secure_renegotiation: bool + client_renegotiations_success_count: int + + # What counts as "limited" per NCSC 3.4.2 + MAX_SECURE_RENEG_ATTEMPTS = 10 + # The number of attempts the scan should make + SCAN_RENEGOTIATION_LIMIT = MAX_SECURE_RENEG_ATTEMPTS + 1 + + @classmethod + def from_session_renegotiation_scan_result(cls, session_renegotiation_scan_result: SessionRenegotiationScanResult): + return cls( + supports_secure_renegotiation=session_renegotiation_scan_result.supports_secure_renegotiation, + client_renegotiations_success_count=session_renegotiation_scan_result.client_renegotiations_success_count, + ) + + @property + def status_secure_renegotiation(self) -> bool: + return self.supports_secure_renegotiation + + @property + def status_client_initiated_renegotiation(self) -> TLSClientInitiatedRenegotiationStatus: + if not self.client_renegotiations_success_count: + return TLSClientInitiatedRenegotiationStatus.not_allowed + if self.client_renegotiations_success_count <= self.MAX_SECURE_RENEG_ATTEMPTS: + return TLSClientInitiatedRenegotiationStatus.allowed_with_low_limit + return TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit + + @property + def score_secure_renegotiation(self) -> scoring.Score: + return ( + scoring.WEB_TLS_SECURE_RENEG_GOOD + if self.supports_secure_renegotiation + else scoring.WEB_TLS_SECURE_RENEG_BAD + ) + + @property + def score_client_initiated_renegotiation(self) -> scoring.Score: + scores = { + TLSClientInitiatedRenegotiationStatus.not_allowed: scoring.WEB_TLS_CLIENT_RENEG_GOOD, + TLSClientInitiatedRenegotiationStatus.allowed_with_low_limit: scoring.WEB_TLS_CLIENT_RENEG_OK, + TLSClientInitiatedRenegotiationStatus.allowed_with_too_high_limit: scoring.WEB_TLS_CLIENT_RENEG_BAD, + } + return scores[self.status_client_initiated_renegotiation] + + +@dataclass(frozen=True) +class KeyExchangeRSAPKCSFunctionEvaluation: + """ + Results of support for PKCS padding for RSA per NCSC 3.3.2.1. + NCSC table 5 + """ + + status: KexRSAPKCSStatus + score: scoring.Score + + @dataclass(frozen=True) class KeyExchangeHashFunctionEvaluation: """ Results of "hash functions for key exchange" evaluation. - NCSC table 5 + NCSC 3.3.5 """ status: KexHashFuncStatus @@ -269,7 +339,6 @@ class TLSCipherOrderEvaluation: If a violation is found, the violation attribute is a two item list with first the cipher preferred by the server, second the cipher we expected to be preferred above that. - NCSC B2-5 """ violation: List[str] @@ -277,6 +346,28 @@ class TLSCipherOrderEvaluation: score: scoring.Score +@dataclass() +class TLSExtendedMasterSecretEvaluation: + """ + Results of evaluating TLS 1.2 Extended Master Secret (RFC7627). + """ + + status: TLSExtendedMasterSecretStatus = TLSExtendedMasterSecretStatus.na_no_tls_1_2 + score: scoring.Score = TLS_EXTENDED_MASTER_SECRET_GOOD + + def update_for_connection(self, ssl_connection: SslConnection, tls_version: TlsVersionEnum) -> None: + if tls_version != TlsVersionEnum.TLS_1_2: + return + + ems_support = ssl_connection.ssl_client.get_extended_master_secret_support() + if ems_support == ExtendedMasterSecretSupportEnum.USED_IN_CURRENT_SESSION: + self.status = TLSExtendedMasterSecretStatus.supported + self.score = TLS_EXTENDED_MASTER_SECRET_GOOD + elif ems_support == ExtendedMasterSecretSupportEnum.NOT_USED_IN_CURRENT_SESSION: + self.status = TLSExtendedMasterSecretStatus.not_supported + self.score = TLS_EXTENDED_MASTER_SECRET_BAD + + def _unique_unhashable(items: List[Any]) -> List[Any]: """ Keep only unique items from a list of unhashable types. diff --git a/checks/tasks/tls/scans.py b/checks/tasks/tls/scans.py index 1d553fd9e..051f8d779 100644 --- a/checks/tasks/tls/scans.py +++ b/checks/tasks/tls/scans.py @@ -11,13 +11,14 @@ from cryptography.hazmat._oid import NameOID from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives._serialization import Encoding -from cryptography.hazmat.primitives.asymmetric import rsa, dsa +from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey from cryptography.x509 import Certificate from django.conf import settings from dns.name import EmptyLabel from dns.resolver import NXDOMAIN, NoAnswer, NoNameservers, LifetimeTimeout from nassl._nassl import OpenSSLError +from nassl.ephemeral_key_info import OpenSslEvpPkeyEnum from nassl.ssl_client import ClientCertificateRequested, OpenSslDigestNidEnum from sslyze import ( ScanCommand, @@ -59,6 +60,7 @@ ZeroRttStatus, KexHashFuncStatus, CipherOrderStatus, + KexRSAPKCSStatus, ) from checks.resolver import dns_resolve_tlsa, DNSSECStatus, dns_resolve_a from checks.tasks.tls import TLSException @@ -69,16 +71,21 @@ KeyExchangeHashFunctionEvaluation, TLSCipherOrderEvaluation, TLSOCSPEvaluation, + KeyExchangeRSAPKCSFunctionEvaluation, + TLSRenegotiationEvaluation, + TLSExtendedMasterSecretEvaluation, ) from checks.tasks.tls.tls_constants import ( CERT_SIGALG_GOOD, - CERT_RSA_DSA_MIN_KEY_SIZE, CERT_CURVES_GOOD, - CERT_CURVE_MIN_KEY_SIZE, CERT_EC_CURVES_GOOD, CERT_EC_CURVES_PHASE_OUT, - SIGNATURE_ALGORITHMS_SHA2, MAIL_ALTERNATE_CONNLIMIT_HOST_SUBSTRS, + CERT_RSA_MIN_GOOD_KEY_SIZE, + CERT_RSA_MIN_PHASE_OUT_KEY_SIZE, + SIGNATURE_ALGORITHMS_BAD_HASH, + SIGNATURE_ALGORITHMS_PHASE_OUT_HASH, + SIGNATURE_ALGORITHMS_RSA_PKCS, ) from internetnl import log @@ -379,7 +386,7 @@ def cert_checks(hostname: str, mode: ChecksMode, af_ip_pair=None, *args, **kwarg trusted_score = trusted_score_good if cert_deployment.verified_certificate_chain else trusted_score_bad pubkey_score, pubkey_bad, pubkey_phase_out = check_pubkey(cert_deployment.received_certificate_chain, mode) - # NCSC guideline B3-2 + # NCSC 3.3.2 / 3.3.5 sigalg_bad = {} sigalg_score = scoring.WEB_TLS_SIGNATURE_GOOD for cert in cert_deployment.received_certificate_chain: @@ -456,7 +463,7 @@ def check_pubkey(certificates: List[Certificate], mode: ChecksMode): """ Check that all provided certificates meet NCSC requirements. """ - # NCSC guidelines B3-3, B5-1 + # NCSC guidelines 3.3.2.x bad_pubkey = [] phase_out_pubkey = [] if mode == ChecksMode.WEB: @@ -471,30 +478,34 @@ def check_pubkey(certificates: List[Certificate], mode: ChecksMode): for cert in certificates: common_name = get_common_name(cert) public_key = cert.public_key() - public_key_type = type(public_key) + key_type = type(public_key) key_size = public_key.key_size + curve = None + if hasattr(public_key, "curve"): + curve = public_key.curve.__class__ + + is_good = ( + (isinstance(public_key, rsa.RSAPublicKey) and key_size >= CERT_RSA_MIN_GOOD_KEY_SIZE) + or (curve in CERT_CURVES_GOOD) + or (isinstance(public_key, EllipticCurvePublicKey) and curve in CERT_EC_CURVES_GOOD) + ) - failed_key_type = "" - curve = "" - # Note that DH fields are checked in the key exchange already - # https://github.com/internetstandards/Internet.nl/pull/1218#issuecomment-1944496933 - if public_key_type is rsa.RSAPublicKey and key_size < CERT_RSA_DSA_MIN_KEY_SIZE: - failed_key_type = public_key_type.__name__ - elif public_key_type is dsa.DSAPublicKey and key_size < CERT_RSA_DSA_MIN_KEY_SIZE: - failed_key_type = public_key_type.__name__ - elif public_key_type in CERT_CURVES_GOOD and key_size < CERT_CURVE_MIN_KEY_SIZE: - failed_key_type = public_key_type.__name__ - elif public_key_type is EllipticCurvePublicKey and public_key.curve not in CERT_EC_CURVES_GOOD: - failed_key_type = public_key_type.__name__ - if failed_key_type: - message = f"{common_name}: {failed_key_type}-{key_size} key_size" - if curve: - message += f", curve: {curve}" - if public_key.curve in CERT_EC_CURVES_PHASE_OUT: - phase_out_pubkey.append(message) - else: - bad_pubkey.append(message) - pubkey_score = pubkey_score_bad + if is_good: + continue + + message = f"{common_name}: {key_type.__name__}-{key_size}" + if curve: + message += f", curve: {curve}" + + is_phase_out = (curve in CERT_EC_CURVES_PHASE_OUT) or ( + isinstance(public_key, rsa.RSAPublicKey) and key_size >= CERT_RSA_MIN_PHASE_OUT_KEY_SIZE + ) + + if is_phase_out: + phase_out_pubkey.append(message) + else: + bad_pubkey.append(message) + pubkey_score = pubkey_score_bad return pubkey_score, bad_pubkey, phase_out_pubkey @@ -513,7 +524,7 @@ def check_mail_tls_multiple(server_tuples) -> Dict[str, Dict[str, Any]]: for future in concurrent.futures.as_completed(future_to_server): server = future_to_server[future] - scan_request = future.result() + scan_request, ems_evaluation = future.result() if scan_request: scans.append(scan_request) @@ -529,7 +540,7 @@ def check_mail_tls_multiple(server_tuples) -> Dict[str, Dict[str, Any]]: results[result.server_location.hostname] = dict(server_reachable=False, tls_enabled=False) continue log.debug(f"sslyze mail scan complete for {result.server_location.hostname}, other scans may be pending") - results[result.server_location.hostname] = check_mail_tls(result, all_suites) + results[result.server_location.hostname] = check_mail_tls(result, all_suites, ems_evaluation) log.debug(f"check_mail_tls complete for {result.server_location.hostname}") return results @@ -547,7 +558,9 @@ def connection_limit_for_scans(scans: List[ServerScanRequest]): return 1 -def _generate_mail_server_scan_request(mx_hostname: str) -> Optional[ServerScanRequest]: +def _generate_mail_server_scan_request( + mx_hostname: str, +) -> Tuple[Optional[ServerScanRequest], TLSExtendedMasterSecretEvaluation]: """ Generate the scan request (sslyze scan commands) for a mail server. Includes resolving and determining supported TLS versions. @@ -556,14 +569,14 @@ def _generate_mail_server_scan_request(mx_hostname: str) -> Optional[ServerScanR server_location = ServerNetworkLocation(hostname=mx_hostname, port=25) except ServerHostnameCouldNotBeResolved: log.info(f"unable to resolve MX host {mx_hostname}, marking server unreachable") - return None + return None, TLSExtendedMasterSecretEvaluation() network_configuration = ServerNetworkConfiguration( tls_server_name_indication=mx_hostname, tls_opportunistic_encryption=ProtocolWithOpportunisticTlsEnum.SMTP, smtp_ehlo_hostname=settings.SMTP_EHLO_DOMAIN, network_timeout=SSLYZE_NETWORK_TIMEOUT, ) - supported_tls_versions = check_supported_tls_versions( + supported_tls_versions, extended_master_secret_evaluation = check_supported_tls_versions( ServerConnectivityInfo( server_location=server_location, network_configuration=network_configuration, @@ -572,22 +585,29 @@ def _generate_mail_server_scan_request(mx_hostname: str) -> Optional[ServerScanR ) if not supported_tls_versions: log.info(f"no TLS version support found for MX host {mx_hostname}, marking server unreachable") - return None + return None, extended_master_secret_evaluation scan_commands = SSLYZE_SCAN_COMMANDS | { SSLYZE_SCAN_COMMANDS_FOR_TLS[tls_version] for tls_version in supported_tls_versions } - return ServerScanRequest( - server_location=server_location, - network_configuration=network_configuration, - scan_commands=scan_commands, - scan_commands_extra_arguments=ScanCommandsExtraArguments( - session_renegotiation=SessionRenegotiationExtraArgument(client_renegotiation_attempts=1), + return ( + ServerScanRequest( + server_location=server_location, + network_configuration=network_configuration, + scan_commands=scan_commands, + scan_commands_extra_arguments=ScanCommandsExtraArguments( + session_renegotiation=SessionRenegotiationExtraArgument(client_renegotiation_attempts=1), + ), ), + extended_master_secret_evaluation, ) -def check_mail_tls(result: ServerScanResult, all_suites: List[CipherSuitesScanAttempt]): +def check_mail_tls( + result: ServerScanResult, + all_suites: List[CipherSuitesScanAttempt], + extended_master_secret_evaluation: TLSExtendedMasterSecretEvaluation, +): """ Perform evaluation and additional probes for a single mail server. This happens after sslyze has already been run on it. @@ -599,15 +619,23 @@ def check_mail_tls(result: ServerScanResult, all_suites: List[CipherSuitesScanAt protocol_evaluation = TLSProtocolEvaluation.from_protocols_accepted(prots_accepted) fs_evaluation = TLSForwardSecrecyParameterEvaluation.from_ciphers_accepted(ciphers_accepted) cipher_evaluation = TLSCipherEvaluation.from_ciphers_accepted(ciphers_accepted) + + server_conn_info = ServerConnectivityInfo( + server_location=result.server_location, + network_configuration=result.network_configuration, + tls_probing_result=result.connectivity_result, + ) cipher_order_evaluation = test_cipher_order( - ServerConnectivityInfo( - server_location=result.server_location, - network_configuration=result.network_configuration, - tls_probing_result=result.connectivity_result, - ), + server_conn_info, prots_accepted, cipher_evaluation, ) + key_exchange_rsa_pkcs_evaluation = test_key_exchange_rsa_pkcs(server_conn_info) + key_exchange_hash_evaluation = test_key_exchange_hash(server_conn_info) + + renegotiation_evaluation = TLSRenegotiationEvaluation.from_session_renegotiation_scan_result( + result.scan_result.session_renegotiation.result + ) cert_results = cert_checks(result.server_location.hostname, ChecksMode.MAIL) # HACK for DANE-TA(2) and hostname mismatch! @@ -629,18 +657,10 @@ def check_mail_tls(result: ServerScanResult, all_suites: List[CipherSuitesScanAt cipher_order_score=cipher_order_evaluation.score, cipher_order=cipher_order_evaluation.status, cipher_order_violation=cipher_order_evaluation.violation, - secure_reneg=result.scan_result.session_renegotiation.result.supports_secure_renegotiation, - secure_reneg_score=( - scoring.WEB_TLS_SECURE_RENEG_GOOD - if result.scan_result.session_renegotiation.result.supports_secure_renegotiation - else scoring.WEB_TLS_SECURE_RENEG_BAD - ), - client_reneg=result.scan_result.session_renegotiation.result.is_vulnerable_to_client_renegotiation_dos, - client_reneg_score=( - scoring.WEB_TLS_CLIENT_RENEG_BAD - if result.scan_result.session_renegotiation.result.is_vulnerable_to_client_renegotiation_dos - else scoring.WEB_TLS_CLIENT_RENEG_GOOD - ), + secure_reneg=renegotiation_evaluation.status_secure_renegotiation, + secure_reneg_score=renegotiation_evaluation.score_secure_renegotiation, + client_reneg=renegotiation_evaluation.status_client_initiated_renegotiation, + client_reneg_score=renegotiation_evaluation.score_client_initiated_renegotiation, compression=result.scan_result.tls_compression.result.supports_compression if result.scan_result.tls_compression.result else None, @@ -664,8 +684,12 @@ def check_mail_tls(result: ServerScanResult, all_suites: List[CipherSuitesScanAt if result.scan_result.tls_1_3_early_data.result.supports_early_data else scoring.WEB_TLS_ZERO_RTT_GOOD ), - kex_hash_func=KexHashFuncStatus.good, - kex_hash_func_score=scoring.WEB_TLS_KEX_HASH_FUNC_OK, + key_exchange_rsa_pkcs=key_exchange_rsa_pkcs_evaluation.status, + key_exchange_rsa_pkcs_score=key_exchange_rsa_pkcs_evaluation.score, + kex_hash_func=key_exchange_hash_evaluation.status, + kex_hash_func_score=key_exchange_hash_evaluation.score, + extended_master_secret=extended_master_secret_evaluation.status, + extended_master_secret_score=extended_master_secret_evaluation.score, ) results.update(cert_results) return results @@ -691,7 +715,7 @@ def check_web_tls(url, af_ip_pair=None, *args, **kwargs): http_user_agent=settings.USER_AGENT, network_timeout=SSLYZE_NETWORK_TIMEOUT, ) - supported_tls_versions = check_supported_tls_versions( + supported_tls_versions, extended_master_secret_evaluation = check_supported_tls_versions( ServerConnectivityInfo( server_location=server_location, network_configuration=network_configuration, @@ -722,21 +746,21 @@ def check_web_tls(url, af_ip_pair=None, *args, **kwargs): protocol_evaluation = TLSProtocolEvaluation.from_protocols_accepted(supported_tls_versions) fs_evaluation = TLSForwardSecrecyParameterEvaluation.from_ciphers_accepted(ciphers_accepted) cipher_evaluation = TLSCipherEvaluation.from_ciphers_accepted(ciphers_accepted) + + server_conn_info = ServerConnectivityInfo( + server_location=result.server_location, + network_configuration=result.network_configuration, + tls_probing_result=result.connectivity_result, + ) cipher_order_evaluation = test_cipher_order( - ServerConnectivityInfo( - server_location=result.server_location, - network_configuration=result.network_configuration, - tls_probing_result=result.connectivity_result, - ), + server_conn_info, supported_tls_versions, cipher_evaluation, ) - key_exchange_hash_evaluation = test_key_exchange_hash( - ServerConnectivityInfo( - server_location=result.server_location, - network_configuration=result.network_configuration, - tls_probing_result=result.connectivity_result, - ), + key_exchange_rsa_pkcs_evaluation = test_key_exchange_rsa_pkcs(server_conn_info) + key_exchange_hash_evaluation = test_key_exchange_hash(server_conn_info) + renegotiation_evaluation = TLSRenegotiationEvaluation.from_session_renegotiation_scan_result( + result.scan_result.session_renegotiation.result ) ocsp_evaluation = TLSOCSPEvaluation.from_certificate_deployments( @@ -756,18 +780,10 @@ def check_web_tls(url, af_ip_pair=None, *args, **kwargs): cipher_order_score=cipher_order_evaluation.score, cipher_order=cipher_order_evaluation.status, cipher_order_violation=cipher_order_evaluation.violation, - secure_reneg=result.scan_result.session_renegotiation.result.supports_secure_renegotiation, - secure_reneg_score=( - scoring.WEB_TLS_SECURE_RENEG_GOOD - if result.scan_result.session_renegotiation.result.supports_secure_renegotiation - else scoring.WEB_TLS_SECURE_RENEG_BAD - ), - client_reneg=result.scan_result.session_renegotiation.result.is_vulnerable_to_client_renegotiation_dos, - client_reneg_score=( - scoring.WEB_TLS_CLIENT_RENEG_BAD - if result.scan_result.session_renegotiation.result.is_vulnerable_to_client_renegotiation_dos - else scoring.WEB_TLS_CLIENT_RENEG_GOOD - ), + secure_reneg=renegotiation_evaluation.status_secure_renegotiation, + secure_reneg_score=renegotiation_evaluation.score_secure_renegotiation, + client_reneg=renegotiation_evaluation.status_client_initiated_renegotiation, + client_reneg_score=renegotiation_evaluation.score_client_initiated_renegotiation, compression=result.scan_result.tls_compression.result.supports_compression, compression_score=( scoring.WEB_TLS_COMPRESSION_BAD @@ -791,8 +807,12 @@ def check_web_tls(url, af_ip_pair=None, *args, **kwargs): ), ocsp_stapling=ocsp_evaluation.status, ocsp_stapling_score=ocsp_evaluation.score, + key_exchange_rsa_pkcs=key_exchange_rsa_pkcs_evaluation.status, + key_exchange_rsa_pkcs_score=key_exchange_rsa_pkcs_evaluation.score, kex_hash_func=key_exchange_hash_evaluation.status, kex_hash_func_score=key_exchange_hash_evaluation.score, + extended_master_secret=extended_master_secret_evaluation.status, + extended_master_secret_score=extended_master_secret_evaluation.score, ) return probe_result @@ -806,7 +826,10 @@ def run_sslyze( This threading is handled inside sslyze. """ log.debug(f"starting sslyze scan for {[scan.server_location for scan in scans]}") - scanner = Scanner(per_server_concurrent_connections_limit=connection_limit, concurrent_server_scans_limit=10) + scanner = Scanner( + per_server_concurrent_connections_limit=connection_limit, + concurrent_server_scans_limit=TLSRenegotiationEvaluation.SCAN_RENEGOTIATION_LIMIT, + ) scanner.queue_scans(scans) for result in scanner.get_results(): log.debug(f"sslyze scan for {result.server_location} result: {result.scan_status}") @@ -850,35 +873,52 @@ def raise_sslyze_errors(result: ServerScanResult) -> None: raise TLSException(str(last_error_trace)) +def test_key_exchange_rsa_pkcs( + server_connectivity_info: ServerConnectivityInfo, +) -> KeyExchangeRSAPKCSFunctionEvaluation: + """ + Test key exchange for RSA PKCS support per NCSC 3.3.2.1. + See also RFC8446 1.3 and 4.2.3, RFC 5246 7.4.1.4.1. + """ + rsa_pkcs_result = _test_connection_with_limited_sigalgs(server_connectivity_info, SIGNATURE_ALGORITHMS_RSA_PKCS) + if rsa_pkcs_result: + log.info(f"RSA-PKCS key exchange check: negotiated bad sigalg ({rsa_pkcs_result})") + return KeyExchangeRSAPKCSFunctionEvaluation( + status=KexRSAPKCSStatus.bad, + score=scoring.TLS_KEX_RSA_PKCS_BAD, + ) + + return KeyExchangeRSAPKCSFunctionEvaluation( + status=KexRSAPKCSStatus.good, + score=scoring.TLS_KEX_RSA_PKCS_GOOD, + ) + + def test_key_exchange_hash( server_connectivity_info: ServerConnectivityInfo, ) -> KeyExchangeHashFunctionEvaluation: """ - Test the SHA2 key exchange per NCSC table 5. + Test key exchange hashes per NCSC 3.3.5. Note that this is not the certificate hash, or TLS cipher hash. There are few or no hosts that do not meet this requirement. """ - ssl_connection = server_connectivity_info.get_preconfigured_tls_connection(should_use_legacy_openssl=False) - ssl_connection.ssl_client.set_signature_algorithms(SIGNATURE_ALGORITHMS_SHA2) - - try: - ssl_connection.connect() - if ssl_connection.ssl_client.get_peer_signature_nid() == OpenSslDigestNidEnum.SHA1: - log.info("Failed SHA2 key exchange check: negotiated SHA1 even when only offering SHA2") - return KeyExchangeHashFunctionEvaluation( - status=KexHashFuncStatus.bad, - score=scoring.WEB_TLS_KEX_HASH_FUNC_BAD, - ) - except ClientCertificateRequested: - pass - except (ServerRejectedTlsHandshake, TlsHandshakeTimedOut, OpenSSLError) as exc: - log.info(f"Failed SHA2 key exchange check: {exc}") + bad_hash_result = _test_connection_with_limited_sigalgs(server_connectivity_info, SIGNATURE_ALGORITHMS_BAD_HASH) + if bad_hash_result: + log.info(f"SHA2 key exchange check: negotiated bad sigalg ({bad_hash_result})") return KeyExchangeHashFunctionEvaluation( status=KexHashFuncStatus.bad, score=scoring.WEB_TLS_KEX_HASH_FUNC_BAD, ) - finally: - ssl_connection.close() + + phase_out_hash_result = _test_connection_with_limited_sigalgs( + server_connectivity_info, SIGNATURE_ALGORITHMS_PHASE_OUT_HASH + ) + if phase_out_hash_result: + log.info(f"SHA2 key exchange check: negotiated phase_out hash ({phase_out_hash_result})") + return KeyExchangeHashFunctionEvaluation( + status=KexHashFuncStatus.phase_out, + score=scoring.WEB_TLS_KEX_HASH_FUNC_OK, + ) return KeyExchangeHashFunctionEvaluation( status=KexHashFuncStatus.good, @@ -886,6 +926,37 @@ def test_key_exchange_hash( ) +def _test_connection_with_limited_sigalgs( + server_connectivity_info: ServerConnectivityInfo, sigalgs: list[tuple[OpenSslDigestNidEnum, OpenSslEvpPkeyEnum]] +) -> Optional[tuple[OpenSslDigestNidEnum, OpenSslEvpPkeyEnum]]: + """ + Test whether the server accepts a connection with limited sigalgs through the signature_algorithms extension. + Returns a (NID, EVP PKEY) if a match was found, None otherwise. + """ + # This is only interesting on TLS 1.2 or older + override_tls_version = None + if server_connectivity_info.tls_probing_result.highest_tls_version_supported == TlsVersionEnum.TLS_1_3: + override_tls_version = TlsVersionEnum.TLS_1_2 + ssl_connection = server_connectivity_info.get_preconfigured_tls_connection( + override_tls_version=override_tls_version, should_use_legacy_openssl=False + ) + ssl_connection.ssl_client.set_signature_algorithms(sigalgs) + + try: + ssl_connection.connect() + sigalg_nid = ssl_connection.ssl_client.get_peer_signature_nid() + # Extra check as some servers will ignore the client, and force a secure hash anyways. + # OpenSSL will accept this, as it does know about the secure hash. + if sigalg_nid in sigalgs: + return sigalg_nid + except (ClientCertificateRequested, ServerRejectedTlsHandshake, TlsHandshakeTimedOut, OpenSSLError): + pass + finally: + ssl_connection.close() + + return None + + def test_cipher_order( server_connectivity_info: ServerConnectivityInfo, tls_versions: List[TlsVersionEnum], @@ -900,9 +971,9 @@ def test_cipher_order( by each good, and then expects the server to choose the good cipher. That assures us that the server prefers each good cipher over any lower cipher. This is tested at all levels that the server supported. - NCSC B2-5. """ cipher_order_violation = [] + status = CipherOrderStatus.good if ( not cipher_evaluation.ciphers_bad and not cipher_evaluation.ciphers_phase_out @@ -918,14 +989,19 @@ def test_cipher_order( order_tuples = [ ( + CipherOrderStatus.sufficient_above_good, cipher_evaluation.ciphers_bad + cipher_evaluation.ciphers_phase_out + cipher_evaluation.ciphers_sufficient, # Make sure we do not mix in TLS 1.3 ciphers, all TLS 1.3 ciphers are good. cipher_evaluation.ciphers_good_no_tls13, ), - (cipher_evaluation.ciphers_bad + cipher_evaluation.ciphers_phase_out, cipher_evaluation.ciphers_sufficient), - (cipher_evaluation.ciphers_bad, cipher_evaluation.ciphers_phase_out), + ( + CipherOrderStatus.bad, + cipher_evaluation.ciphers_bad + cipher_evaluation.ciphers_phase_out, + cipher_evaluation.ciphers_sufficient, + ), + (CipherOrderStatus.bad, cipher_evaluation.ciphers_bad, cipher_evaluation.ciphers_phase_out), ] - for expected_less_preferred, expected_more_preferred_list in order_tuples: + for fail_status, expected_less_preferred, expected_more_preferred_list in order_tuples: if cipher_order_violation: break # Sort CHACHA as later in the list, in case SSL_OP_PRIORITIZE_CHACHA is enabled #461 @@ -938,16 +1014,19 @@ def test_cipher_order( ) if preferred_suite != expected_more_preferred: cipher_order_violation = [preferred_suite.name, expected_more_preferred.name] + status = fail_status log.info( f"found cipher order violation for {server_connectivity_info.server_location.hostname}:" - f" preferred {preferred_suite.name} instead of {expected_more_preferred.name}" + f" preferred {preferred_suite.name} instead of {expected_more_preferred.name}, status {fail_status}" ) break return TLSCipherOrderEvaluation( violation=cipher_order_violation, - status=CipherOrderStatus.bad if cipher_order_violation else CipherOrderStatus.good, - score=scoring.WEB_TLS_CIPHER_ORDER_BAD if cipher_order_violation else scoring.WEB_TLS_CIPHER_ORDER_GOOD, + status=status, + score=scoring.WEB_TLS_CIPHER_ORDER_BAD + if status == CipherOrderStatus.bad + else scoring.WEB_TLS_CIPHER_ORDER_GOOD, ) @@ -998,14 +1077,18 @@ def _check_cipher_suite_available(tls_version: TlsVersionEnum, cipher_suite: Cip return False -def check_supported_tls_versions(server_connectivity_info: ServerConnectivityInfo) -> List[TlsVersionEnum]: +def check_supported_tls_versions( + server_connectivity_info: ServerConnectivityInfo, +) -> Tuple[List[TlsVersionEnum], TLSExtendedMasterSecretEvaluation]: """ - Determine which TLS versions are supported. + Determine which TLS versions are supported, and EMS support. Providing this info to sslyze improves on the bluntness of the scans. + EMS is combined for efficiency, we just need access to any TLS 1.2 connection to check it. """ supported_tls_versions = [] + ems_evaluation = TLSExtendedMasterSecretEvaluation() for tls_version in TlsVersionEnum: - requires_legacy_openssl = tls_version != TlsVersionEnum.TLS_1_3 + requires_legacy_openssl = tls_version not in [TlsVersionEnum.TLS_1_2, TlsVersionEnum.TLS_1_3] ssl_connection = server_connectivity_info.get_preconfigured_tls_connection( override_tls_version=tls_version, should_use_legacy_openssl=requires_legacy_openssl @@ -1013,6 +1096,7 @@ def check_supported_tls_versions(server_connectivity_info: ServerConnectivityInf try: ssl_connection.connect() supported_tls_versions.append(tls_version) + ems_evaluation.update_for_connection(ssl_connection, tls_version) except (ConnectionToServerFailed, OpenSSLError) as exc: log.debug( f"Server {server_connectivity_info.server_location.hostname}" @@ -1028,4 +1112,4 @@ def check_supported_tls_versions(server_connectivity_info: ServerConnectivityInf f"support for {supported_tls_versions}" ) supported_tls_versions.sort(key=lambda t: t.value, reverse=True) - return supported_tls_versions + return supported_tls_versions, ems_evaluation diff --git a/checks/tasks/tls/tasks_reports.py b/checks/tasks/tls/tasks_reports.py index 589632faf..55cc7e5bc 100644 --- a/checks/tasks/tls/tasks_reports.py +++ b/checks/tasks/tls/tasks_reports.py @@ -1,5 +1,6 @@ # Copyright: 2022, ECP, NLnet Labs and the Internet.nl contributors # SPDX-License-Identifier: Apache-2.0 +import itertools import time from timeit import default_timer as timer @@ -274,6 +275,10 @@ def save_results(model, results, addr, domain, category): model.ocsp_stapling_score = result.get("ocsp_stapling_score") model.kex_hash_func = result.get("kex_hash_func") model.kex_hash_func_score = result.get("kex_hash_func_score") + model.key_exchange_rsa_pkcs = result.get("key_exchange_rsa_pkcs") + model.key_exchange_rsa_pkcs_score = result.get("key_exchange_rsa_pkcs_score") + model.extended_master_secret = result.get("extended_master_secret") + model.extended_master_secret_score = result.get("extended_master_secret_score") elif testname == "cert" and result.get("tls_cert"): model.cert_chain = result.get("chain") @@ -349,6 +354,10 @@ def save_results(model, results, addr, domain, category): # model.ocsp_stapling_score = result.get("ocsp_stapling_score") model.kex_hash_func = result.get("kex_hash_func") model.kex_hash_func_score = result.get("kex_hash_func_score") + model.key_exchange_rsa_pkcs = result.get("key_exchange_rsa_pkcs") + model.key_exchange_rsa_pkcs_score = result.get("key_exchange_rsa_pkcs_score") + model.extended_master_secret = result.get("extended_master_secret") + model.extended_master_secret_score = result.get("extended_master_secret_score") if result.get("tls_cert"): model.cert_chain = result.get("chain") model.cert_trusted = result.get("trusted") @@ -444,6 +453,8 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i category.subtests["tls_cipher_order"].result_bad(dttls.cipher_order_violation) elif dttls.cipher_order == CipherOrderStatus.na: category.subtests["tls_cipher_order"].result_na() + elif dttls.cipher_order == CipherOrderStatus.sufficient_above_good: + category.subtests["tls_cipher_order"].result_sufficient_above_good() else: category.subtests["tls_cipher_order"].result_good() @@ -467,10 +478,7 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i else: category.subtests["renegotiation_secure"].result_bad() - if dttls.client_reneg: - category.subtests["renegotiation_client"].result_bad() - else: - category.subtests["renegotiation_client"].result_good() + category.subtests["renegotiation_client"].save_result(dttls.client_reneg) if not dttls.cert_chain: category.subtests["cert_trust"].result_could_not_test() @@ -484,10 +492,11 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i pass else: cert_pubkey_all = annotate_and_combine(dttls.cert_pubkey_bad, dttls.cert_pubkey_phase_out) + cert_pubkey_format = list(itertools.chain(*zip(cert_pubkey_all[0], cert_pubkey_all[1]))) if len(dttls.cert_pubkey_bad) > 0: - category.subtests["cert_pubkey"].result_bad(cert_pubkey_all) + category.subtests["cert_pubkey"].result_bad(cert_pubkey_format) elif len(dttls.cert_pubkey_phase_out) > 0: - category.subtests["cert_pubkey"].result_phase_out(cert_pubkey_all) + category.subtests["cert_pubkey"].result_phase_out(cert_pubkey_format) else: category.subtests["cert_pubkey"].result_good() @@ -570,6 +579,11 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i category.subtests["kex_hash_func"].result_bad() elif dttls.kex_hash_func == KexHashFuncStatus.unknown: category.subtests["kex_hash_func"].result_unknown() + elif dttls.kex_hash_func == KexHashFuncStatus.phase_out: + category.subtests["kex_hash_func"].result_phase_out() + + category.subtests["key_exchange_rsa_pkcs"].save_result(dttls.key_exchange_rsa_pkcs) + category.subtests["extended_master_secret"].save_result(dttls.extended_master_secret) elif isinstance(category, categories.MailTls): if dttls.could_not_test_smtp_starttls: @@ -603,6 +617,8 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i category.subtests["tls_cipher_order"].result_bad(dttls.cipher_order_violation) elif dttls.cipher_order == CipherOrderStatus.na: category.subtests["tls_cipher_order"].result_na() + elif dttls.cipher_order == CipherOrderStatus.sufficient_above_good: + category.subtests["tls_cipher_order"].result_sufficient_above_good() else: category.subtests["tls_cipher_order"].result_good() @@ -626,10 +642,7 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i else: category.subtests["renegotiation_secure"].result_bad() - if dttls.client_reneg: - category.subtests["renegotiation_client"].result_bad() - else: - category.subtests["renegotiation_client"].result_good() + category.subtests["renegotiation_client"].save_result(dttls.client_reneg) if not dttls.cert_chain: category.subtests["cert_trust"].result_could_not_test() @@ -643,10 +656,11 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i pass else: cert_pubkey_all = annotate_and_combine(dttls.cert_pubkey_bad, dttls.cert_pubkey_phase_out) + cert_pubkey_format = list(itertools.chain(*zip(cert_pubkey_all[0], cert_pubkey_all[1]))) if len(dttls.cert_pubkey_bad) > 0: - category.subtests["cert_pubkey"].result_bad(cert_pubkey_all) + category.subtests["cert_pubkey"].result_bad(cert_pubkey_format) elif len(dttls.cert_pubkey_phase_out) > 0: - category.subtests["cert_pubkey"].result_phase_out(cert_pubkey_all) + category.subtests["cert_pubkey"].result_phase_out(cert_pubkey_format) else: category.subtests["cert_pubkey"].result_good() @@ -730,6 +744,11 @@ def annotate_and_combine_all(good_items, sufficient_items, bad_items, phaseout_i category.subtests["kex_hash_func"].result_bad() elif dttls.kex_hash_func == KexHashFuncStatus.unknown: category.subtests["kex_hash_func"].result_unknown() + elif dttls.kex_hash_func == KexHashFuncStatus.phase_out: + category.subtests["kex_hash_func"].result_phase_out() + + category.subtests["key_exchange_rsa_pkcs"].save_result(dttls.key_exchange_rsa_pkcs) + category.subtests["extended_master_secret"].save_result(dttls.extended_master_secret) dttls.report = category.gen_report() diff --git a/checks/tasks/tls/tls_constants.py b/checks/tasks/tls/tls_constants.py index 03c5339b1..3c872f064 100644 --- a/checks/tasks/tls/tls_constants.py +++ b/checks/tasks/tls/tls_constants.py @@ -5,7 +5,7 @@ from sslyze import TlsVersionEnum -# NCSC guideline B3-2 / table 2 and 3 +# NCSC 3.3.2 / 3.3.5 CERT_SIGALG_GOOD = [ SignatureAlgorithmOID.RSA_WITH_SHA256, SignatureAlgorithmOID.RSA_WITH_SHA384, @@ -16,21 +16,30 @@ SignatureAlgorithmOID.DSA_WITH_SHA256, ] -# NCSC table 8 -CERT_RSA_DSA_MIN_KEY_SIZE = 2048 -CERT_CURVE_MIN_KEY_SIZE = 224 +# NCSC 3.3.2.1 +CERT_RSA_MIN_GOOD_KEY_SIZE = 3072 +CERT_RSA_MIN_PHASE_OUT_KEY_SIZE = 2048 -# NCSC table 9 +# NCSC 3.3.2.1 CERT_CURVES_GOOD = [x25519.X25519PublicKey, x448.X448PublicKey] -CERT_EC_CURVES_GOOD = [ec.SECP384R1, ec.SECP256R1] +CERT_EC_CURVES_GOOD = [ + ec.SECP521R1, + ec.SECP384R1, + ec.SECP256R1, + ec.BrainpoolP512R1, + ec.BrainpoolP384R1, + ec.BrainpoolP256R1, +] CERT_EC_CURVES_PHASE_OUT = [ec.SECP224R1] -FS_ECDH_MIN_KEY_SIZE = 224 -FS_DH_MIN_KEY_SIZE = 2048 +# NCSC 3.3.2.1 FS_EC_GOOD = [ OpenSslEcNidEnum.SECP521R1, OpenSslEcNidEnum.SECP384R1, OpenSslEcNidEnum.SECP256R1, + OpenSslEcNidEnum.brainpoolP512r1, + OpenSslEcNidEnum.brainpoolP384r1, + OpenSslEcNidEnum.brainpoolP256r1, OpenSslEcNidEnum.X25519, OpenSslEcNidEnum.X448, ] @@ -39,76 +48,64 @@ ] -# NCSC appendix C, derived from table 2, 6 and 7 -# Anything not in these lists, is insufficient. +# NCSC appendix B, derived from 3.3.3, 3.3.4 +# PQC not yet supported by us +# Anything not in these lists is insufficient. CIPHERS_GOOD = [ "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256", +] +CIPHERS_SUFFICIENT = [ "TLS_AES_128_GCM_SHA256", - # NCSC appendix C lists these as sufficient, but read - # footnote 52 carefully. As we test TLS version separate - # from cipher list, we consider them good. - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_AES_128_CCM_SHA256", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_256_CCM", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_CCM", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - # CCM is not in appendix C, but footnote 31 makes it Good (CCM_8 is insufficient) - "TLS_AES_128_CCM_SHA256", # TLS 1.3 notation - "TLS_ECDHE_ECDSA_WITH_AES_128_CCM", - "TLS_ECDHE_ECDSA_WITH_AES_256_CCM", ] -CIPHERS_SUFFICIENT = [ +CIPHERS_PHASE_OUT = [ + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", - "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", - "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", - "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", - # CAMELLIA is not in appendix C but is sufficient (footnote 31) - "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA", - "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", - "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256", - "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA", - "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256", - "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA", - "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", - "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", - # CCM is not in appendix C, but footnote 31 makes it Good (on its own) - "TLS_DHE_RSA_WITH_AES_128_CCM", + "TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384", + "TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256", + "TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_RSA_WITH_AES_256_CCM", -] -CIPHERS_PHASE_OUT = [ - "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", - "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", - "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", - "TLS_RSA_WITH_AES_256_GCM_SHA384", - "TLS_RSA_WITH_AES_128_GCM_SHA256", - "TLS_RSA_WITH_AES_256_CBC_SHA256", - "TLS_RSA_WITH_AES_256_CBC_SHA", - "TLS_RSA_WITH_AES_128_CBC_SHA256", - "TLS_RSA_WITH_AES_128_CBC_SHA", - "TLS_RSA_WITH_3DES_EDE_CBC_SHA", - # CCM is not in appendix C, but footnote 31 makes it Good (on its own) - "TLS_RSA_WITH_AES_128_CCM", - "TLS_RSA_WITH_AES_256_CCM", + "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", + "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_DHE_RSA_WITH_AES_128_CCM", + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", ] -# NCSC table 1 +# NCSC 3.3.1 PROTOCOLS_GOOD = [ TlsVersionEnum.TLS_1_3, ] @@ -116,27 +113,32 @@ PROTOCOLS_SUFFICIENT = [ TlsVersionEnum.TLS_1_2, ] -PROTOCOLS_PHASE_OUT = [ - TlsVersionEnum.TLS_1_1, - TlsVersionEnum.TLS_1_0, -] +PROTOCOLS_PHASE_OUT = [] -# NCSC table 5 -# This is eventually passed to openssl's SSL_set1_sigalgs, +# These are eventually passed to openssl's SSL_set1_sigalgs, # which requires the NIDs in digest+pubkey algorithm tuples. -SIGNATURE_ALGORITHMS_SHA2 = [ - (OpenSslDigestNidEnum.SHA512, OpenSslEvpPkeyEnum.EC), - (OpenSslDigestNidEnum.SHA384, OpenSslEvpPkeyEnum.EC), - (OpenSslDigestNidEnum.SHA256, OpenSslEvpPkeyEnum.EC), +# NCSC 3.3.5, connection with this set means bad hashes are enabled +SIGNATURE_ALGORITHMS_BAD_HASH = [ + # OpenSSL will not support MD5, or SHA1 with RSA_PSS + (OpenSslDigestNidEnum.SHA1, OpenSslEvpPkeyEnum.EC), + (OpenSslDigestNidEnum.SHA1, OpenSslEvpPkeyEnum.RSA), + (OpenSslDigestNidEnum.SHA1, OpenSslEvpPkeyEnum.DSA), +] +SIGNATURE_ALGORITHMS_PHASE_OUT_HASH = [ + # SHA224 not be tested against RSA_PSS + (OpenSslDigestNidEnum.SHA224, OpenSslEvpPkeyEnum.EC), + (OpenSslDigestNidEnum.SHA224, OpenSslEvpPkeyEnum.RSA), + (OpenSslDigestNidEnum.SHA224, OpenSslEvpPkeyEnum.DSA), +] +# NCSC 3.3.2.1: RSA PKCS must not be used. +# Failing these algs means the server has no RSA or RSA in PSS only, either is fine. +SIGNATURE_ALGORITHMS_RSA_PKCS = [ + # (OpenSslDigestNidEnum.MD5, OpenSslEvpPkeyEnum.RSA), + (OpenSslDigestNidEnum.SHA1, OpenSslEvpPkeyEnum.RSA), + (OpenSslDigestNidEnum.SHA224, OpenSslEvpPkeyEnum.RSA), (OpenSslDigestNidEnum.SHA512, OpenSslEvpPkeyEnum.RSA), (OpenSslDigestNidEnum.SHA384, OpenSslEvpPkeyEnum.RSA), (OpenSslDigestNidEnum.SHA256, OpenSslEvpPkeyEnum.RSA), - (OpenSslDigestNidEnum.SHA512, OpenSslEvpPkeyEnum.RSA_PSS), - (OpenSslDigestNidEnum.SHA384, OpenSslEvpPkeyEnum.RSA_PSS), - (OpenSslDigestNidEnum.SHA256, OpenSslEvpPkeyEnum.RSA_PSS), - (OpenSslDigestNidEnum.SHA512, OpenSslEvpPkeyEnum.DSA), - (OpenSslDigestNidEnum.SHA384, OpenSslEvpPkeyEnum.DSA), - (OpenSslDigestNidEnum.SHA256, OpenSslEvpPkeyEnum.DSA), ] # Mail servers with an increased connection limit, @@ -146,19 +148,6 @@ MAIL_ALTERNATE_CONNLIMIT_HOST_SUBSTRS = {".googlemail.com": 40, ".google.com": 40} # Based on: https://tools.ietf.org/html/rfc7919#appendix-A -FFDHE2048_PRIME = bytearray.fromhex( - "FFFFFFFF FFFFFFFF ADF85458 A2BB4A9A AFDC5620 273D3CF1" - "D8B9C583 CE2D3695 A9E13641 146433FB CC939DCE 249B3EF9" - "7D2FE363 630C75D8 F681B202 AEC4617A D3DF1ED5 D5FD6561" - "2433F51F 5F066ED0 85636555 3DED1AF3 B557135E 7F57C935" - "984F0C70 E0E68B77 E2A689DA F3EFE872 1DF158A1 36ADE735" - "30ACCA4F 483A797A BC0AB182 B324FB61 D108A94B B2C8E3FB" - "B96ADAB7 60D7F468 1D4F42A3 DE394DF4 AE56EDE7 6372BB19" - "0B07A7C8 EE0A6D70 9E02FCE1 CDF7E2EC C03404CD 28342F61" - "9172FE9C E98583FF 8E4F1232 EEF28183 C3FE3B1B 4C6FAD73" - "3BB5FCBC 2EC22005 C58EF183 7D1683B2 C6F34A26 C1B2EFFA" - "886B4238 61285C97 FFFFFFFF FFFFFFFF" -) FFDHE3072_PRIME = bytearray.fromhex( "FFFFFFFF FFFFFFFF ADF85458 A2BB4A9A AFDC5620 273D3CF1" "D8B9C583 CE2D3695 A9E13641 146433FB CC939DCE 249B3EF9" @@ -281,4 +270,5 @@ "D68C8BB7 C5C6424C FFFFFFFF FFFFFFFF" ) FFDHE_GENERATOR = bytearray(b"\x02") # Matched to the type in nassl's DhEphemeralKeyInfo -FFDHE_SUFFICIENT_PRIMES = [FFDHE8192_PRIME, FFDHE6144_PRIME, FFDHE4096_PRIME, FFDHE3072_PRIME] +# NCSC 3.3.3.1 +FFDHE_PHASE_OUT_PRIMES = [FFDHE8192_PRIME, FFDHE6144_PRIME, FFDHE4096_PRIME, FFDHE3072_PRIME] diff --git a/docker/develop.env b/docker/develop.env index 1dfe84f3f..331060009 100644 --- a/docker/develop.env +++ b/docker/develop.env @@ -26,7 +26,7 @@ DEBUG_LOG=True INTERNETNL_LOG_LEVEL=DEBUG # reduce maximum test duration and time before retest can be performed -INTERNETNL_CACHE_TTL=30 +INTERNETNL_CACHE_TTL=300 # allow localhost for healthchecks, the public domain for the app and it's subdomains for connection tests ALLOWED_HOSTS=127.0.0.1,::1,localhost,.internet.test,internet.test,host.docker.internal,host-gateway diff --git a/integration_tests/batch/results.py b/integration_tests/batch/results.py index c7d569eae..14a988fec 100644 --- a/integration_tests/batch/results.py +++ b/integration_tests/batch/results.py @@ -26,11 +26,11 @@ "web_https_http_compress": {"status": "passed", "verdict": "good"}, "web_https_tls_keyexchange": {"status": "passed", "verdict": "good"}, "web_https_tls_ciphers": {"status": "passed", "verdict": "good"}, - "web_https_tls_cipherorder": {"status": "passed", "verdict": "na"}, + "web_https_tls_cipherorder": {"status": "passed", "verdict": "good"}, "web_https_tls_version": {"status": "passed", "verdict": "good"}, "web_https_tls_compress": {"status": "passed", "verdict": "good"}, "web_https_tls_secreneg": {"status": "passed", "verdict": "good"}, - "web_https_tls_clientreneg": {"status": "passed", "verdict": "good"}, + "web_https_tls_clientreneg": {"status": "passed", "verdict": "not-allowed"}, "web_https_cert_chain": {"status": "failed", "verdict": "bad"}, "web_https_cert_pubkey": {"status": "passed", "verdict": "good"}, "web_https_cert_sig": {"status": "passed", "verdict": "good"}, @@ -72,16 +72,18 @@ "kex_params_phase_out": [], "ciphers_bad": [], "ciphers_phase_out": [], - "cipher_order": "na", + "cipher_order": "good", "cipher_order_violation": [], "protocols_bad": [], "protocols_phase_out": [], "compression": False, "secure_reneg": True, - "client_reneg": False, + "client_reneg": "not_allowed", "zero_rtt": "good", "ocsp_stapling": "not_in_cert", "kex_hash_func": "good", + "extended_master_secret": "supported", + "key_exchange_rsa_pkcs": "good", "https_redirect": "good", "http_compression": False, "hsts": True, @@ -136,16 +138,18 @@ "kex_params_phase_out": [], "ciphers_bad": [], "ciphers_phase_out": [], - "cipher_order": "na", + "cipher_order": "good", "cipher_order_violation": [], "protocols_bad": [], "protocols_phase_out": [], "compression": False, "secure_reneg": True, - "client_reneg": False, + "client_reneg": "not_allowed", "zero_rtt": "good", "ocsp_stapling": "not_in_cert", "kex_hash_func": "good", + "extended_master_secret": "supported", + "key_exchange_rsa_pkcs": "good", "https_redirect": "good", "http_compression": False, "hsts": True, diff --git a/interface/batch/__init__.py b/interface/batch/__init__.py index 5da503b23..4fe884db2 100644 --- a/interface/batch/__init__.py +++ b/interface/batch/__init__.py @@ -3,7 +3,7 @@ from django.conf import settings BATCH_API_MAJOR_VERSION = "2" -BATCH_API_MINOR_VERSION = "6" +BATCH_API_MINOR_VERSION = "7" BATCH_API_PATCH_VERSION = "0" BATCH_API_FULL_VERSION = f"{BATCH_API_MAJOR_VERSION}" f".{BATCH_API_MINOR_VERSION}" f".{BATCH_API_PATCH_VERSION}" diff --git a/interface/batch/openapi.yaml b/interface/batch/openapi.yaml index d53d8164d..23b106cc3 100644 --- a/interface/batch/openapi.yaml +++ b/interface/batch/openapi.yaml @@ -628,14 +628,15 @@ components: enumClass: CipherOrderStatus description: | Cipher order preference of the server: - * `bad` - The server does not enforce his own preference. - * `good` - The server enforces his own preference. + * `bad` - The server does not enforce good+sufficient over phase out ciphers. + * `good` - The server enforces his own preference in a correct order. * `not_prescribed` - The server enforces his own preference but - the cipher order is not based on prescribed ordering. + the cipher order is not based on prescribed ordering (deprecated). * `not_seclevel` - The server enforces his own preference but - the configured order is not based on security level. + the configured order is not based on security level (deprecated). * `na` - The server only supports GOOD ciphers; cipher order is not relevant. + * `sufficient_above_good` - the server prefers sufficient ciphers over good. cipher_order_violation: type: array description: | @@ -652,6 +653,15 @@ components: maxItems: 3 items: type: string + extended_master_secret_status: + type: string + enumClass: TLSExtendedMasterSecretStatus + description: | + Server support for Extended Master Secret (RFC7627): + * `supported` - EMS is supported. + * `not_supported` - EMS is not supported. + * `na_no_tls_1_2` - EMS is not applicable, because the server does not support TLS 1.2. + * `unknown` - support could not be determined or test ran before this feature was added. protocols_bad: type: array description: List of BAD protocols. @@ -672,18 +682,30 @@ components: If secure renegotiation is supported by the server. (TLS1.3 offers only secure renegotiation) client_reneg: - type: boolean - description: > - If client initiated renegotiation is supported by the server. - (TLS1.3 does not support client renegotiation). + type: string + enumClass: TLSClientInitiatedRenegotiationStatus + description: | + Client-initiated renegotiation: + * `not_allowed` - not allowed by server. + * `allowed_with_low_limit` - allowed by server, but with a sufficiently low limit (<10). + * `allowed_with_too_high_limit` - allowed by server, with a limit that is too high (>=10). + kex_rsa_pkcs: + type: string + enumClass: KexRSAPKCSStatus + description: | + RSA PKCS#1 v1.5 support: + * `good` - server does not support RSA PKCS#1 v1.5 padding. + * `bad` - server supports RSA PKCS#1 v1.5 padding. + * `unknown` - support could not be determined or test ran before this feature was added. kex_hash_func: type: string enumClass: KexHashFuncStatus description: | - SHA2 support for signatures of the server: - * `bad` - SHA2 is not supported. - * `good` - SHA2 is supported. - * `unknown` - SHA2 support could not be determined (the server + Supported hashes for signatures: + * `good` - server supports only good hashes (SHA256 or newer). + * `bad` - server supports MD5 or SHA1. + * `phase_out` - server supports SHA224. + * `unknown` - hash support could not be determined (the server uses RSA key exchange or anonymous ciphers). zero_rtt: type: string diff --git a/interface/templates/domain-results.html b/interface/templates/domain-results.html index c8cfcacc0..fcdca844b 100644 --- a/interface/templates/domain-results.html +++ b/interface/templates/domain-results.html @@ -42,11 +42,13 @@

{% include "details-test-item.html" with testitem=details.tls_cipher_order %} {% include "details-test-item.html" with testitem=details.fs_params %} {% include "details-test-item.html" with testitem=details.kex_hash_func %} + {% include "details-test-item.html" with testitem=details.key_exchange_rsa_pkcs %} {% include "details-test-item.html" with testitem=details.tls_compression %} {% include "details-test-item.html" with testitem=details.renegotiation_secure %} {% include "details-test-item.html" with testitem=details.renegotiation_client %} {% include "details-test-item.html" with testitem=details.zero_rtt %} {% include "details-test-item.html" with testitem=details.ocsp_stapling %} + {% include "details-test-item.html" with testitem=details.extended_master_secret %}
{% include "string.html" with name="results domain-mail tls certificate label" %}
diff --git a/interface/templates/mail-results.html b/interface/templates/mail-results.html index abe9fb992..720cc1e50 100644 --- a/interface/templates/mail-results.html +++ b/interface/templates/mail-results.html @@ -59,10 +59,12 @@

{% include "details-test-item.html" with testitem=details.tls_cipher_order %} {% include "details-test-item.html" with testitem=details.fs_params %} {% include "details-test-item.html" with testitem=details.kex_hash_func %} + {% include "details-test-item.html" with testitem=details.key_exchange_rsa_pkcs %} {% include "details-test-item.html" with testitem=details.tls_compression %} {% include "details-test-item.html" with testitem=details.renegotiation_secure %} {% include "details-test-item.html" with testitem=details.renegotiation_client %} {% include "details-test-item.html" with testitem=details.zero_rtt %} + {% include "details-test-item.html" with testitem=details.extended_master_secret %}
{% include "string.html" with name="results domain-mail tls certificate label" %}
diff --git a/requirements.txt b/requirements.txt index 24324d60b..06e262c65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,9 @@ beautifulsoup4==4.13.3 billiard==4.2.1 # via celery bleach[css]==5.0.1 - # via django-bleach + # via + # bleach + # django-bleach cached-property==2.0.1 # via -r requirements.in celery==5.4.0 @@ -63,8 +65,7 @@ cryptography==44.0.2 # -r requirements.in # pgpy-dtc # pyopenssl - # sslyze -django==4.2.24 +django==4.2.22 # via # -r requirements.in # django-bleach @@ -225,6 +226,11 @@ statshog==1.0.6 # via -r requirements.in tinycss2==1.1.1 # via bleach +tls-parser==2.0.1 + # via -r requirements.in + # via sslyze +tinycss2==1.1.1 + # via bleach tls-parser==2.0.1 # via sslyze tomli==2.2.1 @@ -234,8 +240,6 @@ tomli==2.2.1 typing-extensions==4.12.2 # via # asgiref - # beautifulsoup4 - # exceptiongroup # kombu # pydantic # pydantic-core