diff --git a/.github-assets/trustpoint_banner.png b/.github-assets/trustpoint_banner.png deleted file mode 100644 index c1d9b9a79..000000000 Binary files a/.github-assets/trustpoint_banner.png and /dev/null differ diff --git a/.github-assets/trustpoint_github_banner.svg b/.github-assets/trustpoint_github_banner.svg new file mode 100644 index 000000000..9c92e209e --- /dev/null +++ b/.github-assets/trustpoint_github_banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/README.md b/README.md index 67a48886a..c11df6b78 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Trustpoint](.github-assets/trustpoint_banner.png) +![Trustpoint](.github-assets/trustpoint_github_banner.svg)
diff --git a/docs/source/_static/trustpoint_banner.png b/docs/source/_static/trustpoint_banner.png deleted file mode 100644 index c1d9b9a79..000000000 Binary files a/docs/source/_static/trustpoint_banner.png and /dev/null differ diff --git a/docs/source/_static/trustpoint_github_banner.svg b/docs/source/_static/trustpoint_github_banner.svg new file mode 100644 index 000000000..9c92e209e --- /dev/null +++ b/docs/source/_static/trustpoint_github_banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/source/cra/CONTROLS.md b/docs/source/cra/CONTROLS.md index 197b5f4ea..73e81b154 100644 --- a/docs/source/cra/CONTROLS.md +++ b/docs/source/cra/CONTROLS.md @@ -1,5 +1,5 @@

- Trustpoint Logo + Trustpoint Logo

# Security Controls diff --git a/docs/source/cra/CRA_COMPLIANCE.md b/docs/source/cra/CRA_COMPLIANCE.md index 553cd2cc2..8610489f5 100644 --- a/docs/source/cra/CRA_COMPLIANCE.md +++ b/docs/source/cra/CRA_COMPLIANCE.md @@ -1,5 +1,5 @@

- Trustpoint Logo + Trustpoint Logo

# CRA Conformity Assessment diff --git a/docs/source/cra/RISK_REGISTER.md b/docs/source/cra/RISK_REGISTER.md index 1dd17f10a..22e0d0b22 100644 --- a/docs/source/cra/RISK_REGISTER.md +++ b/docs/source/cra/RISK_REGISTER.md @@ -1,5 +1,5 @@

- Trustpoint Logo + Trustpoint Logo

# Risk Register diff --git a/docs/source/cra/THREAT_MODEL.md b/docs/source/cra/THREAT_MODEL.md index bed02780d..552982c27 100644 --- a/docs/source/cra/THREAT_MODEL.md +++ b/docs/source/cra/THREAT_MODEL.md @@ -1,5 +1,5 @@

- Trustpoint Logo + Trustpoint Logo

# Threat Model diff --git a/docs/source/index.rst b/docs/source/index.rst index 1c283a7c7..4f9590fd4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,7 +3,7 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -.. image:: /_static/trustpoint_banner.png +.. image:: /_static/trustpoint_github_banner.svg :align: center ====================================== diff --git a/trustpoint/management/forms.py b/trustpoint/management/forms.py index e17888e26..59d0a81ae 100644 --- a/trustpoint/management/forms.py +++ b/trustpoint/management/forms.py @@ -47,7 +47,6 @@ from pki.models.truststore import TruststoreModel from pki.services.external_csr import certificate_matches_credential, parse_single_certificate from pki.services.key_generation import supported_key_type_choices -from pki.util.keys import AutoGenPkiKeyAlgorithm, supported_auto_gen_pki_key_algorithms from pki.util.x509 import CertificateVerifier from trustpoint.logger import LoggerMixin @@ -204,7 +203,7 @@ class SecurityConfigForm(forms.ModelForm[SecurityConfig]): """Security configuration model form.""" FEATURE_TO_FIELDS: ClassVar[dict[type[SecurityFeature], list[str]]] = { - AutoGenPkiFeature: ['auto_gen_pki', 'auto_gen_pki_key_algorithm'], + AutoGenPkiFeature: ['auto_gen_pki'], } def __init__(self, *args: Any, **kwargs: Any)-> None: @@ -229,26 +228,6 @@ def __init__(self, *args: Any, **kwargs: Any)-> None: if field_name in self.fields: self.fields[field_name].widget.attrs['disabled'] = 'disabled' - supported_algorithms = supported_auto_gen_pki_key_algorithms() - self.supported_auto_gen_pki_key_algorithms = supported_algorithms - - if self.instance and self.instance.auto_gen_pki: - self.fields['auto_gen_pki_key_algorithm'].widget.attrs['disabled'] = 'disabled' - elif 'auto_gen_pki_key_algorithm' in self.fields: - auto_gen_pki_key_algorithm_field = cast( - 'forms.ChoiceField', - self.fields['auto_gen_pki_key_algorithm'], - ) - if supported_algorithms: - auto_gen_pki_key_algorithm_field.choices = [ - (algorithm.value, algorithm.label) for algorithm in supported_algorithms - ] - else: - auto_gen_pki_key_algorithm_field.choices = [ - ('', _('No supported backend algorithms available')), - ] - auto_gen_pki_key_algorithm_field.widget.attrs['disabled'] = 'disabled' - self.helper = FormHelper() self.helper.layout = Layout( Fieldset( @@ -258,15 +237,22 @@ def __init__(self, *args: Any, **kwargs: Any)-> None: ), Fieldset( _('Advanced security settings'), - Field('auto_gen_pki', wrapper_class='form-check form-switch'), - 'auto_gen_pki_key_algorithm', 'rsa_minimum_key_size', 'max_cert_validity_days', 'max_crl_validity_days', 'credential_ttl_seconds', Field('allow_ca_issuance', wrapper_class='form-check form-switch'), - Field('allow_auto_gen_pki', wrapper_class='form-check form-switch'), Field('allow_self_signed_ca', wrapper_class='form-check form-switch'), + Field('auto_gen_pki', wrapper_class='form-check form-switch'), + HTML( + "{% load i18n %}" + "{% if form.instance.auto_gen_pki %}" + '

' + '' + "{% trans 'Create an auto-generated PKI' %}" + '

' + "{% endif %}" + ), Field('allow_imported_private_keys', wrapper_class='form-check form-switch'), 'permitted_no_onboarding_pki_protocols', 'permitted_onboarding_protocols' @@ -285,7 +271,10 @@ def __init__(self, *args: Any, **kwargs: Any)-> None: auto_gen_pki = forms.BooleanField( required=False, - label=_('Enable local auto-generated PKI'), + label=_('Allow local auto-generated PKI creation'), + help_text=_( + 'Turning this off disables the local auto-generated PKI and revokes all certificates issued by it.' + ), widget=forms.CheckboxInput( attrs={ 'class': 'form-check-input', @@ -297,13 +286,6 @@ def __init__(self, *args: Any, **kwargs: Any)-> None: ), ) - auto_gen_pki_key_algorithm = forms.ChoiceField( - choices=AutoGenPkiKeyAlgorithm, - label=_('Key Algorithm for auto-generated PKI'), - required=False, - widget=forms.Select(attrs={'data-hide-at-sl': '[false, false, true, true, true]'}), - ) - RSA_KEY_CHOICES: ClassVar[list[tuple[object, object]]] = [ ('', _('None / Not Permitted')), (1024, '1024'), @@ -343,10 +325,10 @@ class Meta: """Meta configuration for SecurityConfigForm.""" model = SecurityConfig fields: ClassVar[list[str]] = [ - 'security_mode', 'auto_gen_pki', 'auto_gen_pki_key_algorithm', + 'security_mode', 'rsa_minimum_key_size', 'max_cert_validity_days', 'max_crl_validity_days', - 'credential_ttl_seconds', - 'allow_ca_issuance', 'allow_auto_gen_pki', 'allow_self_signed_ca', + 'credential_ttl_seconds', 'auto_gen_pki', + 'allow_ca_issuance', 'allow_self_signed_ca', 'allow_imported_private_keys', 'permitted_no_onboarding_pki_protocols', 'permitted_onboarding_protocols' @@ -402,24 +384,6 @@ def clean_rsa_minimum_key_size(self) -> int | None: except (TypeError, ValueError) as err: raise ValidationError(_('Invalid RSA key size.')) from err - def clean_auto_gen_pki_key_algorithm(self) -> AutoGenPkiKeyAlgorithm: - """Keep the current value of `auto_gen_pki_key_algorithm` from the instance if the field was disabled.""" - form_value = self.cleaned_data.get('auto_gen_pki_key_algorithm') - if form_value is None or form_value == '': - if self.instance: - return AutoGenPkiKeyAlgorithm(self.instance.auto_gen_pki_key_algorithm) - return AutoGenPkiKeyAlgorithm.RSA2048 - selected_algorithm = AutoGenPkiKeyAlgorithm(form_value) - supported_algorithms = getattr( - self, - 'supported_auto_gen_pki_key_algorithms', - supported_auto_gen_pki_key_algorithms(), - ) - if selected_algorithm not in supported_algorithms: - msg = _('The selected auto-generated PKI algorithm is not supported by the active backend.') - raise ValidationError(msg) - return selected_algorithm - def _validate_mode_constraints(self, cleaned: dict[str, Any], mode: str) -> None: """Validate that submitted values comply with the given security mode defaults.""" defaults = SecurityConfig._MODE_DEFAULTS[mode] # noqa: SLF001 @@ -461,22 +425,15 @@ def clean(self) -> dict[str, Any]: if mode != SecurityConfig.SecurityModeChoices.LAB: self._validate_mode_constraints(cleaned, str(mode)) - if cleaned.get('auto_gen_pki') and not cleaned.get('allow_auto_gen_pki'): - self.add_error('auto_gen_pki', 'Cannot enable auto-generated PKI when it is not permitted.') + selected_mode = str(mode) + mode_defaults = SecurityConfig._MODE_DEFAULTS.get(selected_mode) # noqa: SLF001 + if self.instance and selected_mode == self.instance.security_mode: + auto_gen_pki_allowed = self.instance.allow_auto_gen_pki + else: + auto_gen_pki_allowed = mode_defaults['allow_auto_gen_pki'] if mode_defaults else False - supported_algorithms = getattr( - self, - 'supported_auto_gen_pki_key_algorithms', - supported_auto_gen_pki_key_algorithms(), - ) - selected_algorithm = cleaned.get('auto_gen_pki_key_algorithm') - if cleaned.get('auto_gen_pki') and not supported_algorithms: - self.add_error('auto_gen_pki', _('No auto-generated PKI algorithm is supported by the active backend.')) - elif cleaned.get('auto_gen_pki') and selected_algorithm not in supported_algorithms: - self.add_error( - 'auto_gen_pki_key_algorithm', - _('The selected auto-generated PKI algorithm is not supported by the active backend.'), - ) + if cleaned.get('auto_gen_pki') and not auto_gen_pki_allowed: + self.add_error('auto_gen_pki', 'Cannot enable auto-generated PKI when it is not permitted.') return cleaned diff --git a/trustpoint/management/tests/test_forms/test_security_config.py b/trustpoint/management/tests/test_forms/test_security_config.py index c1456d16f..09235dca4 100644 --- a/trustpoint/management/tests/test_forms/test_security_config.py +++ b/trustpoint/management/tests/test_forms/test_security_config.py @@ -7,7 +7,6 @@ from management.forms import SecurityConfigForm from management.models import SecurityConfig from onboarding.enums import NoOnboardingPkiProtocol, OnboardingProtocol -from pki.util.keys import AutoGenPkiKeyAlgorithm class SecurityConfigFormTest(TestCase): @@ -18,7 +17,6 @@ def setUp(self): self.config = SecurityConfig.objects.create( security_mode=SecurityConfig.SecurityModeChoices.BROWNFIELD, auto_gen_pki=False, - auto_gen_pki_key_algorithm=AutoGenPkiKeyAlgorithm.RSA2048 ) def test_form_initialization_with_instance(self): @@ -26,7 +24,8 @@ def test_form_initialization_with_instance(self): form = SecurityConfigForm(instance=self.config) self.assertIn('security_mode', form.fields) self.assertIn('auto_gen_pki', form.fields) - self.assertIn('auto_gen_pki_key_algorithm', form.fields) + assert 'allow_auto_gen_pki' not in form.fields + assert 'auto_gen_pki_key_algorithm' not in form.fields assert 'allow_imported_private_keys' in form.fields def test_form_initialization_without_instance(self): @@ -66,7 +65,6 @@ def test_form_with_dev_security_mode(self): form_data = { 'security_mode': SecurityConfig.SecurityModeChoices.LAB, 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, 'allow_auto_gen_pki': True, } form = SecurityConfigForm(data=form_data, instance=self.config) @@ -77,7 +75,6 @@ def test_form_with_high_security_mode(self): form_data = { 'security_mode': SecurityConfig.SecurityModeChoices.HARDENED, 'auto_gen_pki': False, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, # Hardened defaults from _MODE_DEFAULTS 'rsa_minimum_key_size': 4096, 'max_cert_validity_days': 365, @@ -90,78 +87,6 @@ def test_form_with_high_security_mode(self): form = SecurityConfigForm(data=form_data, instance=self.config) self.assertTrue(form.is_valid()) - def test_clean_auto_gen_pki_key_algorithm_with_none(self): - """Test clean method returns instance value when form value is not provided.""" - self.config.auto_gen_pki = True - self.config.auto_gen_pki_key_algorithm = AutoGenPkiKeyAlgorithm.SECP256R1 - self.config.save() - - form_data = { - 'security_mode': SecurityConfig.SecurityModeChoices.BROWNFIELD, - 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.SECP256R1, - # Brownfield defaults from _MODE_DEFAULTS - 'rsa_minimum_key_size': 1024, - 'max_cert_validity_days': 1825, - 'max_crl_validity_days': 365, - 'allow_ca_issuance': False, - 'allow_auto_gen_pki': True, - 'allow_self_signed_ca': True, - } - form = SecurityConfigForm(data=form_data, instance=self.config) - self.assertTrue(form.is_valid()) - result = form.cleaned_data['auto_gen_pki_key_algorithm'] - self.assertEqual(result, AutoGenPkiKeyAlgorithm.SECP256R1) - - def test_clean_auto_gen_pki_key_algorithm_returns_provided_value(self): - """Test clean method uses the provided algorithm value.""" - form_data = { - 'security_mode': SecurityConfig.SecurityModeChoices.BROWNFIELD, - 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, - # Brownfield defaults from _MODE_DEFAULTS - 'rsa_minimum_key_size': 1024, - 'max_cert_validity_days': 1825, - 'max_crl_validity_days': 365, - 'allow_ca_issuance': False, - 'allow_auto_gen_pki': True, - 'allow_self_signed_ca': True, - } - form = SecurityConfigForm(data=form_data) - self.assertTrue(form.is_valid()) - result = form.cleaned_data['auto_gen_pki_key_algorithm'] - self.assertEqual(result, AutoGenPkiKeyAlgorithm.RSA2048) - - def test_clean_auto_gen_pki_key_algorithm_with_value(self): - """Test clean method uses provided value when available.""" - form_data = { - 'security_mode': SecurityConfig.SecurityModeChoices.BROWNFIELD, - 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA4096, - # Brownfield defaults from _MODE_DEFAULTS - 'rsa_minimum_key_size': 1024, - 'max_cert_validity_days': 1825, - 'max_crl_validity_days': 365, - 'allow_ca_issuance': False, - 'allow_auto_gen_pki': True, - 'allow_self_signed_ca': True, - } - form = SecurityConfigForm(data=form_data, instance=self.config) - self.assertTrue(form.is_valid()) - result = form.cleaned_data['auto_gen_pki_key_algorithm'] - self.assertEqual(result, AutoGenPkiKeyAlgorithm.RSA4096) - - def test_form_disables_algorithm_field_when_auto_gen_pki_enabled(self): - """Test that algorithm field is disabled when auto_gen_pki is already enabled.""" - self.config.auto_gen_pki = True - self.config.save() - - form = SecurityConfigForm(instance=self.config) - self.assertEqual( - form.fields['auto_gen_pki_key_algorithm'].widget.attrs.get('disabled'), - 'disabled' - ) - def test_form_initialization_with_data_security_mode(self): """Test form initialization considers security_mode from form data.""" form_data = { @@ -185,7 +110,6 @@ def test_all_security_modes(self): form_data = { 'security_mode': mode, 'auto_gen_pki': defaults['allow_auto_gen_pki'], - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, 'rsa_minimum_key_size': defaults['rsa_minimum_key_size'] or '', 'max_cert_validity_days': defaults['max_cert_validity_days'], 'max_crl_validity_days': defaults['max_crl_validity_days'], @@ -209,7 +133,6 @@ def test_form_saves_imported_private_key_policy(self) -> None: data={ 'security_mode': SecurityConfig.SecurityModeChoices.LAB, 'auto_gen_pki': False, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, 'allow_imported_private_keys': True, }, instance=self.config, @@ -252,7 +175,6 @@ def test_protocol_allowlists_are_saved_as_int_lists() -> None: config = SecurityConfig.objects.create( security_mode=SecurityConfig.SecurityModeChoices.LAB, auto_gen_pki=False, - auto_gen_pki_key_algorithm=AutoGenPkiKeyAlgorithm.RSA2048, ) no_onboarding_values = [ @@ -268,7 +190,6 @@ def test_protocol_allowlists_are_saved_as_int_lists() -> None: data={ 'security_mode': SecurityConfig.SecurityModeChoices.LAB, 'auto_gen_pki': False, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, 'permitted_no_onboarding_pki_protocols': no_onboarding_values, 'permitted_onboarding_protocols': onboarding_values, }, diff --git a/trustpoint/management/tests/test_views/test_backend_configuration.py b/trustpoint/management/tests/test_views/test_backend_configuration.py index 5a7a5e96a..b3bf3b5b6 100644 --- a/trustpoint/management/tests/test_views/test_backend_configuration.py +++ b/trustpoint/management/tests/test_views/test_backend_configuration.py @@ -73,7 +73,7 @@ def test_get_context_data_with_software_config(self) -> None: assert context['is_software_backend'] assert context['capability_badge'] == 'success' assert context['supported_key_capabilities'] - assert context['supported_auto_gen_pki_algorithms'] + assert context['supported_auto_gen_pki_key_types'] assert 'page_title' in context def test_get_context_data_with_softhsm_config(self) -> None: diff --git a/trustpoint/management/tests/test_views/test_settings.py b/trustpoint/management/tests/test_views/test_settings.py index 5c82980ab..c4da34b31 100644 --- a/trustpoint/management/tests/test_views/test_settings.py +++ b/trustpoint/management/tests/test_views/test_settings.py @@ -16,7 +16,7 @@ from management.forms import SecurityConfigForm, SmtpEmailConfigForm, SmtpEmailTestForm from management.models import LoggingConfig, SecurityConfig, SmtpEmailConfig from management.views.settings import ChangeLogLevelView, SecuritySettingsView, SettingsTabView, MetricsSettingsView -from pki.util.keys import AutoGenPkiKeyAlgorithm +from pki.models import CaModel, DomainModel LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] @@ -154,8 +154,8 @@ def test_form_valid_resets_settings_on_security_mode_increase(self, mock_apply): @patch('management.security.features.AutoGenPkiFeature.enable') @patch.object(SecurityConfig, 'apply_security_settings') - def test_form_valid_enables_auto_gen_pki(self, mock_apply, mock_enable): - """Test form_valid enables AutoGenPkiFeature when auto_gen_pki is enabled.""" + def test_form_valid_does_not_generate_auto_gen_pki_when_allowed(self, mock_apply, mock_enable): + """Allowing AutoGenPKI in settings does not create it.""" mock_sec = Mock() mock_sec.enable_feature = Mock() self.view.sec = mock_sec @@ -166,13 +166,15 @@ def test_form_valid_enables_auto_gen_pki(self, mock_apply, mock_enable): form.changed_data = ['auto_gen_pki'] form.cleaned_data = { 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, } form.save = Mock() self.view.form_valid(form) - mock_sec.enable_feature.assert_called_once() + mock_enable.assert_not_called() + mock_sec.enable_feature.assert_not_called() + self.assertFalse(CaModel.objects.exists()) + self.assertFalse(DomainModel.objects.exists()) @patch('management.security.features.AutoGenPkiFeature.disable') @patch.object(SecurityConfig, 'apply_security_settings') @@ -225,24 +227,6 @@ def test_form_valid_handles_missing_security_mode(self): messages_list = list(get_messages(self.view.request)) self.assertTrue(any('missing' in str(msg).lower() for msg in messages_list)) - def test_form_valid_handles_missing_key_algorithm(self): - """Test form_valid handles missing key algorithm when enabling auto_gen_pki.""" - form = Mock(spec=SecurityConfigForm) - form.instance = self.security_config - form.instance.pk = 1 - form.changed_data = ['auto_gen_pki'] - form.cleaned_data = { - 'auto_gen_pki': True, - 'auto_gen_pki_key_algorithm': None, - } - form.save = Mock() - - with patch.object(SecurityConfig, 'apply_security_settings'): - self.view.form_valid(form) - - messages_list = list(get_messages(self.view.request)) - self.assertTrue(any('missing' in str(msg).lower() for msg in messages_list)) - def test_form_invalid_shows_error_message(self): """Test form_invalid displays error message.""" form = Mock(spec=SecurityConfigForm) @@ -264,7 +248,6 @@ def test_form_invalid_renders_full_settings_page(self): data={ 'security_mode': SecurityConfig.SecurityModeChoices.HARDENED, 'auto_gen_pki': False, - 'auto_gen_pki_key_algorithm': AutoGenPkiKeyAlgorithm.RSA2048, 'rsa_minimum_key_size': 1024, 'max_cert_validity_days': 365, 'max_crl_validity_days': 90, diff --git a/trustpoint/management/views/backend_configuration.py b/trustpoint/management/views/backend_configuration.py index 8965cc7d8..bb6bcec5d 100644 --- a/trustpoint/management/views/backend_configuration.py +++ b/trustpoint/management/views/backend_configuration.py @@ -20,7 +20,7 @@ from appsecrets.models import AppSecretBackendModel from crypto.application.capabilities import BackendCapabilityReport, get_active_backend_capability_report from crypto.models import BackendKind, CryptoProviderProfileModel -from pki.util.keys import supported_auto_gen_pki_key_algorithms +from pki.services.key_generation import supported_key_type_choices PKCS11_ASSET_DOWNLOADS = { 'module': ('module_path', 'application/octet-stream'), @@ -258,7 +258,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context['capability_message'] = capability_message context['capability_diagnostics'] = capability_report.diagnostics if capability_report is not None else () context['supported_key_capabilities'] = _supported_key_capabilities(capability_report) - context['supported_auto_gen_pki_algorithms'] = supported_auto_gen_pki_key_algorithms() + context['supported_auto_gen_pki_key_types'] = supported_key_type_choices() pkcs11_capability_payload = ( getattr(pkcs11_probe_detail, 'snapshot_payload', None) if pkcs11_probe_detail is not None diff --git a/trustpoint/management/views/settings.py b/trustpoint/management/views/settings.py index 077276141..5892dd69a 100644 --- a/trustpoint/management/views/settings.py +++ b/trustpoint/management/views/settings.py @@ -46,7 +46,6 @@ from management.models.workflows2 import WorkflowExecutionConfig from management.security.features import AutoGenPkiFeature from management.security.mixins import SecurityLevelMixin -from pki.util.keys import AutoGenPkiKeyAlgorithm from trustpoint.logger import LoggerMixin from trustpoint.page_context import PageContextMixin from trustpoint.views.base import UserPermissionRequiredMixin @@ -623,17 +622,7 @@ def form_valid(self, form: SecurityConfigForm) -> HttpResponse: new_auto = form.cleaned_data.get('auto_gen_pki', None) self.logger.info('auto_gen_pki changed: old=%s, new=%s', old_auto, new_auto) - if old_auto != new_auto and new_auto: - key_alg_value = form.cleaned_data.get('auto_gen_pki_key_algorithm') - if key_alg_value is None: - messages.error(self.request, 'Auto-generated PKI key algorithm is missing.') - return redirect(self.success_url) - key_alg = AutoGenPkiKeyAlgorithm(key_alg_value) - self.logger.info('Calling enable_feature for AutoGenPkiFeature with key_alg: %s', key_alg) - self.sec.enable_feature(AutoGenPkiFeature, {'key_algorithm': key_alg}) - self.logger.info('Auto-generated PKI enabled with key algorithm: %s', key_alg.name) - - elif old_auto != new_auto and not new_auto: + if old_auto != new_auto and not new_auto: AutoGenPkiFeature.disable() self.logger.info('Auto-generated PKI disabled') diff --git a/trustpoint/pki/auto_gen_pki.py b/trustpoint/pki/auto_gen_pki.py index 789ba8df1..71be886da 100644 --- a/trustpoint/pki/auto_gen_pki.py +++ b/trustpoint/pki/auto_gen_pki.py @@ -15,11 +15,10 @@ ManagedRSAPrivateKey, generate_managed_signing_private_key, ) -from crypto.domain.algorithms import EllipticCurveName -from crypto.domain.specs import EcKeySpec, KeySpec, MlDsaKeySpec, MlDsaVariant, RsaKeySpec from crypto.models import CryptoManagedKeyModel from pki.models import CaModel, CredentialModel, DomainModel, RevokedCertificateModel -from pki.util.keys import AutoGenPkiKeyAlgorithm, supported_auto_gen_pki_key_algorithms +from pki.services.key_generation import key_spec_for_key_type, supported_key_type_choices +from pki.util.keys import AutoGenPkiKeyAlgorithm from pki.util.x509 import CertificateGenerator from trustpoint.logger import LoggerMixin @@ -37,36 +36,39 @@ class AutoGenPki(LoggerMixin): _lock: threading.Lock = threading.Lock() @staticmethod - def _key_spec_for_algorithm(key_alg: AutoGenPkiKeyAlgorithm) -> KeySpec: - """Map AutoGenPKI choices to the backend key-generation contract.""" - if key_alg == AutoGenPkiKeyAlgorithm.RSA2048: - return RsaKeySpec(key_size=2048) - if key_alg == AutoGenPkiKeyAlgorithm.RSA4096: - return RsaKeySpec(key_size=4096) - if key_alg == AutoGenPkiKeyAlgorithm.SECP256R1: - return EcKeySpec(curve=EllipticCurveName.SECP256R1) - if key_alg == AutoGenPkiKeyAlgorithm.MLDSA44: - return MlDsaKeySpec(variant=MlDsaVariant.MLDSA44) - if key_alg == AutoGenPkiKeyAlgorithm.MLDSA65: - return MlDsaKeySpec(variant=MlDsaVariant.MLDSA65) - if key_alg == AutoGenPkiKeyAlgorithm.MLDSA87: - return MlDsaKeySpec(variant=MlDsaVariant.MLDSA87) - msg = f'Unsupported AutoGenPKI key algorithm {key_alg!r}.' - raise ValueError(msg) + def _normalize_key_type(key_type: str | AutoGenPkiKeyAlgorithm) -> str: + """Convert the legacy algorithm enum to the shared key-generation service format.""" + if isinstance(key_type, AutoGenPkiKeyAlgorithm): + name = key_type.name + if name.startswith('RSA'): + return f'RSA-{name.removeprefix("RSA")}' + if name.startswith('SECP'): + return f'ECC-{name}' + if name.startswith('MLDSA'): + return f'MLDSA-{name.removeprefix("MLDSA")}' + msg = f'Unsupported AutoGenPKI key algorithm {key_type!r}.' + raise ValueError(msg) + return key_type + + @classmethod + def _key_type_identifier(cls, key_type: str | AutoGenPkiKeyAlgorithm) -> str: + """Return a stable name suffix for Root CA, Issuing CA and Domain records.""" + return cls._normalize_key_type(key_type).replace('-', '') @staticmethod def _generate_private_key( - key_alg: AutoGenPkiKeyAlgorithm, + key_type: str, key_label: str, ) -> ManagedRSAPrivateKey | ManagedECPrivateKey | ManagedMLDSAPrivateKey: """Generate an AutoGenPKI key in the active backend.""" - if key_alg not in supported_auto_gen_pki_key_algorithms(): - msg = f'The active crypto backend does not support AutoGenPKI algorithm {key_alg.label}.' + supported_types = {value for value, _label in supported_key_type_choices()} + if key_type not in supported_types: + msg = f'Unsupported AutoGenPKI key type for the active crypto backend: {key_type}.' raise ValueError(msg) return generate_managed_signing_private_key( alias=key_label, - key_spec=AutoGenPki._key_spec_for_algorithm(key_alg), + key_spec=key_spec_for_key_type(key_type), ) @staticmethod @@ -105,11 +107,11 @@ def _save_managed_issuing_ca( # noqa: PLR0913 return ca @classmethod - def get_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm | None = None) -> CaModel | None: + def get_auto_gen_pki(cls, key_type: str | AutoGenPkiKeyAlgorithm | None = None) -> CaModel | None: """Retrieves the auto-generated PKI Issuing CA, if it exists.""" - if key_alg is not None: + if key_type is not None: return CaModel.objects.filter( - unique_name__startswith=f'{UNIQUE_NAME_PREFIX}_{key_alg.name}', + unique_name__startswith=f'{UNIQUE_NAME_PREFIX}_{cls._key_type_identifier(key_type)}', ca_type=CaModel.CaTypeChoice.AUTOGEN, is_active=True, ).first() @@ -120,25 +122,36 @@ def get_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm | None = None) -> CaMo ).first() @classmethod - def enable_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm) -> None: + def enable_auto_gen_pki( + cls, + key_type: str | AutoGenPkiKeyAlgorithm | None = None, + *, + key_alg: AutoGenPkiKeyAlgorithm | None = None, + ) -> CaModel | None: """Enables the auto-generated PKI.""" + key_type = key_type or key_alg + if key_type is None: + msg = 'An AutoGenPKI key type is required.' + raise ValueError(msg) + key_type = cls._normalize_key_type(key_type) + key_identifier = cls._key_type_identifier(key_type) with cls._lock: - cls.logger.warning('! Enabling auto-generated PKI with key algorithm: %s !', key_alg.name) + cls.logger.warning('! Enabling auto-generated PKI with key type: %s !', key_type) unique_suffix = secrets.token_hex(4) - issuing_ca_unique_name = f'{UNIQUE_NAME_PREFIX}_{key_alg.name}_{unique_suffix}' - domain_unique_name = f'{DOMAIN_NAME_PREFIX}_{key_alg.name}' + issuing_ca_unique_name = f'{UNIQUE_NAME_PREFIX}_{key_identifier}_{unique_suffix}' + domain_unique_name = f'{DOMAIN_NAME_PREFIX}_{key_identifier}' - existing_issuing_ca = cls.get_auto_gen_pki(key_alg) + existing_issuing_ca = cls.get_auto_gen_pki() if existing_issuing_ca: cls.logger.error( - 'Issuing CA for auto-generated PKI already exists: %s - ' + 'An active auto-generated PKI already exists: %s - ' 'auto-generated PKI was possibly not correctly disabled', existing_issuing_ca.unique_name ) - return + return None - root_ca_name = f'AutoGenPKI_Root_CA_{key_alg.name}' + root_ca_name = f'AutoGenPKI_Root_CA_{key_identifier}' # Re-use any existing root CA for the auto-generated PKI and current key type try: root_ca = CaModel.objects.get( @@ -149,7 +162,7 @@ def enable_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm) -> None: cls.logger.info('Reusing existing Root CA: %s', root_ca_name) except CaModel.DoesNotExist: cls.logger.info('Creating new Root CA: %s', root_ca_name) - root_private_key = cls._generate_private_key(key_alg, f'{root_ca_name}_{unique_suffix}') + root_private_key = cls._generate_private_key(key_type, f'{root_ca_name}_{unique_suffix}') root_cert, _ = CertificateGenerator.create_root_ca( root_ca_name, private_key=root_private_key, # type: ignore[arg-type] @@ -165,7 +178,7 @@ def enable_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm) -> None: cls.logger.info('Created new Root CA: %s', root_ca_name) cls.logger.info('Creating new Issuing CA with unique name: %s', issuing_ca_unique_name) - issuing_private_key = cls._generate_private_key(key_alg, issuing_ca_unique_name) + issuing_private_key = cls._generate_private_key(key_type, issuing_ca_unique_name) issuing_1, _ = CertificateGenerator.create_issuing_ca( root_1_key, root_ca_name, @@ -195,7 +208,8 @@ def enable_auto_gen_pki(cls, key_alg: AutoGenPkiKeyAlgorithm) -> None: domain.save() cls.logger.info('Domain %s updated and activated', domain_unique_name) - cls.logger.warning('Auto-generated PKI enabled with key algorithm: %s', key_alg.name) + cls.logger.warning('Auto-generated PKI enabled with key type: %s', key_type) + return issuing_ca @classmethod def disable_auto_gen_pki(cls) -> None: @@ -205,7 +219,7 @@ def disable_auto_gen_pki(cls) -> None: Managed backend keys are not destroyed - each Issuing CA has a unique name to avoid conflicts. """ with cls._lock: - issuing_ca = cls.get_auto_gen_pki(key_alg=None) + issuing_ca = cls.get_auto_gen_pki(key_type=None) if not issuing_ca: cls.logger.error( 'Issuing CA for auto-generated PKI does not exist - auto-generated PKI possibly not fully disabled' diff --git a/trustpoint/pki/forms/__init__.py b/trustpoint/pki/forms/__init__.py index 5ae4a765f..978d3d681 100644 --- a/trustpoint/pki/forms/__init__.py +++ b/trustpoint/pki/forms/__init__.py @@ -8,6 +8,7 @@ from .csr import CsrIssuanceForm from .devids import DevIdAddMethodSelectForm, DevIdRegistrationForm from .issuing_cas import ( + IssuingCaAddAutoGenForm, IssuingCaAddFileImportPkcs12Form, IssuingCaAddFileImportSeparateFilesForm, IssuingCaAddMethodSelectForm, @@ -35,6 +36,7 @@ 'CsrIssuanceForm', 'DevIdAddMethodSelectForm', 'DevIdRegistrationForm', + 'IssuingCaAddAutoGenForm', 'IssuingCaAddFileImportPkcs12Form', 'IssuingCaAddFileImportSeparateFilesForm', 'IssuingCaAddMethodSelectForm', diff --git a/trustpoint/pki/forms/issuing_cas.py b/trustpoint/pki/forms/issuing_cas.py index 2df99eebf..256a43141 100644 --- a/trustpoint/pki/forms/issuing_cas.py +++ b/trustpoint/pki/forms/issuing_cas.py @@ -305,6 +305,38 @@ class IssuingCaAddMethodSelectForm(forms.Form): ) +class IssuingCaAddAutoGenForm(forms.Form): + """Form for generating the local auto-generated PKI.""" + + has_supported_key_types: bool + + key_type = forms.ChoiceField( + label=_('Key Type'), + choices=KEY_TYPE_CHOICES, + initial='RSA-2048', + required=True, + widget=forms.Select(attrs={'class': 'form-select'}), + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Use only key types supported by the active crypto backend.""" + super().__init__(*args, **kwargs) + choices = supported_key_type_choices() + self.has_supported_key_types = bool(choices) + key_type_field = cast('forms.ChoiceField', self.fields['key_type']) + key_type_field.choices = choices or [('', _('No supported backend algorithms available'))] + if not choices: + key_type_field.widget.attrs['disabled'] = 'disabled' + + def clean_key_type(self) -> str: + """Reject key types that the active backend cannot generate.""" + key_type = cast('str', self.cleaned_data['key_type']) + supported_types = {value for value, _label in supported_key_type_choices()} + if key_type not in supported_types: + raise ValidationError(_('The active crypto backend does not support this key type.')) + return key_type + + class IssuingCaFileTypeSelectForm(forms.Form): """Form for selecting the file type when importing an Issuing CA. diff --git a/trustpoint/pki/tests/test_auto_gen_pki.py b/trustpoint/pki/tests/test_auto_gen_pki.py index 709091b30..00893b020 100644 --- a/trustpoint/pki/tests/test_auto_gen_pki.py +++ b/trustpoint/pki/tests/test_auto_gen_pki.py @@ -3,7 +3,6 @@ """Tests for the auto-generated PKI.""" -from typing import cast from unittest import mock import pytest @@ -93,23 +92,22 @@ def disable_auto_gen_pki() -> None: @pytest.mark.parametrize( - ('key_alg', 'expected_type'), + ('key_alg', 'expected_key_type'), [ - (AutoGenPkiKeyAlgorithm.RSA2048, 'RsaKeySpec'), - (AutoGenPkiKeyAlgorithm.RSA4096, 'RsaKeySpec'), - (AutoGenPkiKeyAlgorithm.SECP256R1, 'EcKeySpec'), - (AutoGenPkiKeyAlgorithm.MLDSA44, 'MlDsaKeySpec'), - (AutoGenPkiKeyAlgorithm.MLDSA65, 'MlDsaKeySpec'), - (AutoGenPkiKeyAlgorithm.MLDSA87, 'MlDsaKeySpec'), + (AutoGenPkiKeyAlgorithm.RSA2048, 'RSA-2048'), + (AutoGenPkiKeyAlgorithm.RSA4096, 'RSA-4096'), + (AutoGenPkiKeyAlgorithm.SECP256R1, 'ECC-SECP256R1'), + (AutoGenPkiKeyAlgorithm.MLDSA44, 'MLDSA-44'), + (AutoGenPkiKeyAlgorithm.MLDSA65, 'MLDSA-65'), + (AutoGenPkiKeyAlgorithm.MLDSA87, 'MLDSA-87'), ], ) -def test_key_spec_for_supported_algorithms(key_alg: AutoGenPkiKeyAlgorithm, expected_type: str) -> None: - """Each public AutoGenPKI algorithm maps to its backend key specification.""" - key_spec_for_algorithm = getattr(AutoGenPki, '_key_spec_for_algorithm') - assert type(key_spec_for_algorithm(key_alg)).__name__ == expected_type +def test_legacy_algorithm_normalizes_to_shared_key_type(key_alg: AutoGenPkiKeyAlgorithm, expected_key_type: str) -> None: + """Legacy AutoGen callers normalize to the shared key-generation service format.""" + assert AutoGenPki._normalize_key_type(key_alg) == expected_key_type def test_key_spec_for_unknown_algorithm_rejects_invalid_value() -> None: """Unknown AutoGenPKI choices fail before backend interaction.""" with pytest.raises(ValueError, match='Unsupported'): - getattr(AutoGenPki, '_key_spec_for_algorithm')(cast(AutoGenPkiKeyAlgorithm, 'invalid')) + AutoGenPki._generate_private_key('invalid', 'test-key') diff --git a/trustpoint/pki/tests/test_auto_gen_pki_lifecycle.py b/trustpoint/pki/tests/test_auto_gen_pki_lifecycle.py index 90d1c7c61..06d80b8cd 100644 --- a/trustpoint/pki/tests/test_auto_gen_pki_lifecycle.py +++ b/trustpoint/pki/tests/test_auto_gen_pki_lifecycle.py @@ -13,17 +13,19 @@ from pki.auto_gen_pki import DOMAIN_NAME_PREFIX, UNIQUE_NAME_PREFIX, AutoGenPki from pki.models import CaModel, DomainModel from pki.models.certificate import CertificateModel -from pki.util.keys import AutoGenPkiKeyAlgorithm, supported_auto_gen_pki_key_algorithms +from pki.services.key_generation import supported_key_type_choices +from pki.util.keys import AutoGenPkiKeyAlgorithm pytestmark = pytest.mark.django_db KEY_ALGORITHM = AutoGenPkiKeyAlgorithm.RSA2048 +KEY_TYPE = 'RSA-2048' @pytest.fixture(autouse=True) def _require_backend_support() -> None: """Skip when the active crypto backend cannot generate the test key.""" - if KEY_ALGORITHM not in supported_auto_gen_pki_key_algorithms(): + if KEY_TYPE not in {value for value, _label in supported_key_type_choices()}: pytest.skip('Active crypto backend does not support RSA-2048 AutoGenPKI keys.') diff --git a/trustpoint/pki/tests/test_util_keys_extended.py b/trustpoint/pki/tests/test_util_keys_extended.py index ff1a87198..9d80d650e 100644 --- a/trustpoint/pki/tests/test_util_keys_extended.py +++ b/trustpoint/pki/tests/test_util_keys_extended.py @@ -5,9 +5,6 @@ from __future__ import annotations -from types import SimpleNamespace -from unittest.mock import patch - import pytest from cryptography.hazmat.primitives import hashes from trustpoint_core.oid import NamedCurve, PublicKeyAlgorithmOid @@ -15,7 +12,6 @@ from pki.util.keys import ( AutoGenPkiKeyAlgorithm, CryptographyUtils, - supported_auto_gen_pki_key_algorithms, ) pytestmark = pytest.mark.django_db @@ -46,65 +42,6 @@ def test_rsa_and_ec_variants_carry_their_parameters(self) -> None: ) -class TestSupportedAlgorithms: - """Discovery of algorithms supported by the active crypto backend.""" - - def _report(self, **overrides: object) -> SimpleNamespace: - report = { - 'available': True, - 'backend_kind': 'software', - 'diagnostics': [], - 'supports_rsa_key_size': lambda _size: True, - 'supports_ec_curve': lambda _curve: True, - 'supports_key_spec': lambda _spec: True, - } - report.update(overrides) - return SimpleNamespace(**report) - - def test_unavailable_backend_supports_nothing(self) -> None: - """An unavailable backend offers no AutoGenPKI algorithms.""" - report = self._report(available=False, diagnostics=['backend offline']) - - with patch('crypto.application.capabilities.get_active_backend_capability_report', return_value=report): - assert supported_auto_gen_pki_key_algorithms() == () - - def test_software_backend_offers_classical_and_mldsa(self) -> None: - """A capable software backend offers RSA, EC and ML-DSA algorithms.""" - with patch( - 'crypto.application.capabilities.get_active_backend_capability_report', - return_value=self._report(), - ): - supported = supported_auto_gen_pki_key_algorithms() - - assert AutoGenPkiKeyAlgorithm.RSA2048 in supported - assert AutoGenPkiKeyAlgorithm.SECP256R1 in supported - assert AutoGenPkiKeyAlgorithm.MLDSA44 in supported - - def test_hardware_backend_omits_mldsa(self) -> None: - """Non-software backends do not offer ML-DSA algorithms.""" - with patch( - 'crypto.application.capabilities.get_active_backend_capability_report', - return_value=self._report(backend_kind='pkcs11'), - ): - supported = supported_auto_gen_pki_key_algorithms() - - assert AutoGenPkiKeyAlgorithm.RSA2048 in supported - assert AutoGenPkiKeyAlgorithm.MLDSA44 not in supported - - def test_unsupported_rsa_sizes_are_excluded(self) -> None: - """RSA variants the backend cannot generate are not offered.""" - report = self._report( - supports_rsa_key_size=lambda size: size == 2048, - supports_ec_curve=lambda _curve: False, - supports_key_spec=lambda _spec: False, - ) - - with patch('crypto.application.capabilities.get_active_backend_capability_report', return_value=report): - supported = supported_auto_gen_pki_key_algorithms() - - assert supported == (AutoGenPkiKeyAlgorithm.RSA2048,) - - class TestHashAlgorithmSelection: """Hash algorithm selection for signing keys.""" diff --git a/trustpoint/pki/tests/test_views_issuing_cas_autogen.py b/trustpoint/pki/tests/test_views_issuing_cas_autogen.py new file mode 100644 index 000000000..adc7a1cf8 --- /dev/null +++ b/trustpoint/pki/tests/test_views_issuing_cas_autogen.py @@ -0,0 +1,152 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Tests for AutoGenPKI generation from the Add Issuing CA page.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.messages import get_messages +from django.test import Client +from django.urls import reverse + +from management.models.audit_log import AuditLog +from management.models.security import SecurityConfig +from pki.auto_gen_pki import AutoGenPki +from pki.forms.issuing_cas import IssuingCaAddAutoGenForm +from pki.models import CaModel, DomainModel + + +@pytest.fixture +def manage_cas_client(db: None) -> Client: + """Return an authenticated client allowed to manage CAs.""" + user = get_user_model().objects.create_user(username='autogen-admin', password='test-password') + permission = Permission.objects.get(codename='manage_cas') + user.role.permissions.add(permission) + client = Client() + client.force_login(user) + return client + + +def _security_config(*, allowed: bool) -> SecurityConfig: + return SecurityConfig.objects.create( + security_mode=SecurityConfig.SecurityModeChoices.LAB, + auto_gen_pki=allowed, + allow_auto_gen_pki=True, + ) + + +@pytest.mark.django_db +def test_add_issuing_ca_page_keeps_autogen_section_visible_when_disabled(manage_cas_client: Client) -> None: + """The separate section remains visible but its action is disabled by settings.""" + _security_config(allowed=False) + + response = manage_cas_client.get(reverse('pki:issuing_cas-add-method_select')) + + assert response.status_code == 200 + assert b'Auto-generated PKI' in response.content + assert b'Auto-generated PKI creation is disabled in the Security Settings.' in response.content + assert response.context['auto_gen_pki_form'].fields['key_type'].widget.attrs['disabled'] == 'disabled' + assert response.context['can_generate_auto_gen_pki'] is False + + +@pytest.mark.django_db +def test_add_issuing_ca_page_disables_action_when_an_autogen_pki_is_active(manage_cas_client: Client) -> None: + """The active PKI is linked and cannot be generated again from the page.""" + _security_config(allowed=True) + active_ca = Mock(pk=42) + + with patch.object(AutoGenPki, 'get_auto_gen_pki', return_value=active_ca): + response = manage_cas_client.get(reverse('pki:issuing_cas-add-method_select')) + + assert response.status_code == 200 + assert b'An auto-generated PKI is already active.' in response.content + assert b'/pki/issuing-cas/detail/42/' in response.content + assert response.context['can_generate_auto_gen_pki'] is False + + +def test_autogen_form_uses_central_key_generation_choices() -> None: + """The AutoGen form takes its backend-filtered choices from key_generation.""" + choices = [('ECC-SECP384R1', 'ECC SECP384R1')] + with patch('pki.forms.issuing_cas.supported_key_type_choices', return_value=choices): + form = IssuingCaAddAutoGenForm() + + assert list(form.fields['key_type'].choices) == choices + + +@pytest.mark.django_db +def test_autogen_endpoint_rejects_user_without_manage_cas(db: None) -> None: + """Only users with MANAGE_CAS can submit the generation endpoint.""" + user = get_user_model().objects.create_user(username='autogen-reader', password='test-password') + client = Client() + client.force_login(user) + _security_config(allowed=True) + client.raise_request_exception = False + + response = client.post(reverse('pki:issuing_cas-add-autogen'), {'key_type': 'RSA-2048'}) + + assert response.status_code == 403 + assert not CaModel.objects.filter(ca_type=CaModel.CaTypeChoice.AUTOGEN).exists() + + +@pytest.mark.django_db +def test_autogen_endpoint_rejects_when_security_settings_disallow_creation(manage_cas_client: Client) -> None: + """The server rejects a forged POST even when the page's control is disabled.""" + _security_config(allowed=False) + + with patch.object(AutoGenPki, 'enable_auto_gen_pki') as generate: + response = manage_cas_client.post(reverse('pki:issuing_cas-add-autogen'), {'key_type': 'RSA-2048'}) + + assert response.status_code == 302 + generate.assert_not_called() + + +@pytest.mark.django_db +def test_autogen_endpoint_rejects_duplicate_active_pki(manage_cas_client: Client) -> None: + """The server blocks duplicate generation even for a direct POST.""" + _security_config(allowed=True) + + with ( + patch.object(AutoGenPki, 'get_auto_gen_pki', return_value=Mock()), + patch.object(AutoGenPki, 'enable_auto_gen_pki') as generate, + ): + response = manage_cas_client.post(reverse('pki:issuing_cas-add-autogen'), {'key_type': 'RSA-2048'}) + + assert response.status_code == 302 + generate.assert_not_called() + + +@pytest.mark.django_db +def test_autogen_endpoint_rejects_unsupported_key_type(manage_cas_client: Client) -> None: + """A key type absent from the backend-filtered form choices cannot be submitted.""" + _security_config(allowed=True) + + with patch.object(AutoGenPki, 'enable_auto_gen_pki') as generate: + response = manage_cas_client.post(reverse('pki:issuing_cas-add-autogen'), {'key_type': 'not-a-key-type'}) + + assert response.status_code == 302 + generate.assert_not_called() + + +@pytest.mark.django_db +def test_autogen_endpoint_generates_cas_domain_audit_and_success_message(manage_cas_client: Client) -> None: + """A valid POST generates the lifecycle and redirects with an audit entry.""" + _security_config(allowed=True) + + response = manage_cas_client.post(reverse('pki:issuing_cas-add-autogen'), {'key_type': 'RSA-2048'}) + + assert response.status_code == 302 + assert response.url == reverse('pki:issuing_cas') + issuing_ca = AutoGenPki.get_auto_gen_pki() + assert issuing_ca is not None + assert issuing_ca.parent_ca is not None + assert CaModel.objects.filter(ca_type=CaModel.CaTypeChoice.AUTOGEN_ROOT).count() == 1 + domain = DomainModel.objects.get(issuing_ca=issuing_ca) + assert domain.is_active + audit_entry = AuditLog.objects.get(operation_type=AuditLog.OperationType.CA_CREATED) + assert audit_entry.target == issuing_ca + assert any('Successfully generated auto-generated PKI.' in str(message) for message in get_messages(response.wsgi_request)) \ No newline at end of file diff --git a/trustpoint/pki/urls.py b/trustpoint/pki/urls.py index 563305542..5d5f3a0a1 100644 --- a/trustpoint/pki/urls.py +++ b/trustpoint/pki/urls.py @@ -136,6 +136,11 @@ issuing_cas.IssuingCaAddMethodSelectView.as_view(), name='issuing_cas-add-method_select', ), + path( + 'issuing-cas/add/autogen/', + issuing_cas.IssuingCaAddAutoGenView.as_view(), + name='issuing_cas-add-autogen', + ), path( 'issuing-cas/add/file-import/pkcs12', issuing_cas.IssuingCaAddFileImportPkcs12View.as_view(), diff --git a/trustpoint/pki/util/keys.py b/trustpoint/pki/util/keys.py index 8727716c6..9ebfd9eca 100644 --- a/trustpoint/pki/util/keys.py +++ b/trustpoint/pki/util/keys.py @@ -5,7 +5,6 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING, cast, get_args from cryptography.hazmat.primitives import hashes @@ -34,9 +33,6 @@ from pki.models.credential import CredentialModel from pki.models.domain import DomainModel -logger = logging.getLogger(__name__) - - class AutoGenPkiKeyAlgorithm(models.TextChoices): """The key algorithms supported by the AutoGenPKI.""" @@ -66,39 +62,6 @@ def to_public_key_info(self) -> PublicKeyInfo: raise ValueError(exc_msg) -def supported_auto_gen_pki_key_algorithms() -> tuple[AutoGenPkiKeyAlgorithm, ...]: - """Return AutoGenPKI algorithms supported by the active crypto backend.""" - from crypto.application.capabilities import get_active_backend_capability_report # noqa: PLC0415 - from crypto.domain.specs import MlDsaKeySpec, MlDsaVariant # noqa: PLC0415 - - report = get_active_backend_capability_report() - if not report.available: - logger.warning( - 'Could not determine supported AutoGenPKI algorithms for backend %r: %s', - report.backend_kind, - '; '.join(report.diagnostics) or 'backend unavailable', - ) - return () - - supported: list[AutoGenPkiKeyAlgorithm] = [] - if report.supports_rsa_key_size(2048): - supported.append(AutoGenPkiKeyAlgorithm.RSA2048) - if report.supports_rsa_key_size(4096): - supported.append(AutoGenPkiKeyAlgorithm.RSA4096) - if report.supports_ec_curve(ec.SECP256R1()): - supported.append(AutoGenPkiKeyAlgorithm.SECP256R1) - - if report.backend_kind == 'software': - if report.supports_key_spec(MlDsaKeySpec(variant=MlDsaVariant.MLDSA44)): - supported.append(AutoGenPkiKeyAlgorithm.MLDSA44) - if report.supports_key_spec(MlDsaKeySpec(variant=MlDsaVariant.MLDSA65)): - supported.append(AutoGenPkiKeyAlgorithm.MLDSA65) - if report.supports_key_spec(MlDsaKeySpec(variant=MlDsaVariant.MLDSA87)): - supported.append(AutoGenPkiKeyAlgorithm.MLDSA87) - - return tuple(supported) - - class KeyGenerator: """Utility class for generating private keys.""" diff --git a/trustpoint/pki/views/issuing_cas.py b/trustpoint/pki/views/issuing_cas.py index f8a36192d..ea49f12f1 100644 --- a/trustpoint/pki/views/issuing_cas.py +++ b/trustpoint/pki/views/issuing_cas.py @@ -38,8 +38,11 @@ from rest_framework.response import Response from management.models.audit_log import AuditLog +from management.models.security import SecurityConfig +from pki.auto_gen_pki import AutoGenPki from pki.forms import ( CertificateIssuanceForm, + IssuingCaAddAutoGenForm, IssuingCaAddFileImportPkcs12Form, IssuingCaAddFileImportSeparateFilesForm, IssuingCaAddMethodSelectForm, @@ -149,6 +152,31 @@ class IssuingCaAddMethodSelectView(IssuingCaContextMixin, FormView[IssuingCaAddM template_name = 'pki/issuing_cas/add/method_select.html' form_class = IssuingCaAddMethodSelectForm + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Add the independent AutoGenPKI form and its current availability.""" + context = super().get_context_data(**kwargs) + auto_gen_form = IssuingCaAddAutoGenForm() + security_config = SecurityConfig.objects.first() + active_auto_gen_pki = AutoGenPki.get_auto_gen_pki() + can_manage_cas = self.request.user.has_perm(AppPermissions.MANAGE_CAS) + auto_gen_allowed = bool(security_config and security_config.auto_gen_pki) + key_type_field = cast('ChoiceField', auto_gen_form.fields['key_type']) + can_generate = ( + auto_gen_allowed + and active_auto_gen_pki is None + and can_manage_cas + and auto_gen_form.has_supported_key_types + ) + if not can_generate: + key_type_field.widget.attrs['disabled'] = 'disabled' + context.update({ + 'auto_gen_pki_form': auto_gen_form, + 'auto_gen_pki_allowed': auto_gen_allowed, + 'active_auto_gen_pki': active_auto_gen_pki, + 'can_generate_auto_gen_pki': can_generate, + }) + return context + def form_valid(self, form: IssuingCaAddMethodSelectForm) -> HttpResponseRedirect: """Redirect to the next step based on the selected method.""" method_select = form.cleaned_data.get('method_select') @@ -167,6 +195,54 @@ def form_valid(self, form: IssuingCaAddMethodSelectForm) -> HttpResponseRedirect return HttpResponseRedirect(reverse_lazy('pki:issuing_cas-add-method_select')) +class IssuingCaAddAutoGenView(IssuingCaContextMixin, FormView[IssuingCaAddAutoGenForm]): + """Generate the local AutoGenPKI from the dedicated Add Issuing CA action.""" + + form_class = IssuingCaAddAutoGenForm + template_name = 'pki/issuing_cas/add/method_select.html' + success_url = reverse_lazy('pki:issuing_cas') + + def form_invalid(self, form: IssuingCaAddAutoGenForm) -> HttpResponseRedirect: + """Return to method selection with validation feedback.""" + for error in form.errors.values(): + messages.error(self.request, ' '.join(str(message) for message in error)) + return redirect('pki:issuing_cas-add-method_select') + + def form_valid(self, form: IssuingCaAddAutoGenForm) -> HttpResponseRedirect: + """Enforce permissions and current security state before creating any CA.""" + if not self.request.user.has_perm(AppPermissions.MANAGE_CAS): + raise PermissionDenied + + security_config = SecurityConfig.objects.first() + if not security_config or not security_config.auto_gen_pki: + messages.error(self.request, _('Auto-generated PKI creation is disabled in the Security Settings.')) + return redirect('pki:issuing_cas-add-method_select') + + if AutoGenPki.get_auto_gen_pki() is not None: + messages.warning(self.request, _('An auto-generated PKI is already active.')) + return redirect('pki:issuing_cas-add-method_select') + + try: + issuing_ca = AutoGenPki.enable_auto_gen_pki(form.cleaned_data['key_type']) + except ValueError as error: + messages.error(self.request, str(error)) + return redirect('pki:issuing_cas-add-method_select') + + if issuing_ca is None: + messages.warning(self.request, _('An auto-generated PKI is already active.')) + return redirect('pki:issuing_cas-add-method_select') + + actor = self.request.user if self.request.user.is_authenticated else None + AuditLog.create_entry( + operation_type=AuditLog.OperationType.CA_CREATED, + target=issuing_ca, + target_display=f'CA: {issuing_ca.unique_name}', + actor=actor, + ) + messages.success(self.request, _('Successfully generated auto-generated PKI.')) + return redirect(self.success_url) + + class IssuingCaAddFileImportPkcs12View(IssuingCaContextMixin, FormView[IssuingCaAddFileImportPkcs12Form]): """View to import an Issuing CA from a PKCS12 file.""" diff --git a/trustpoint/templates/management/backend_configuration.html b/trustpoint/templates/management/backend_configuration.html index 33ad80fa7..b829c0d0b 100644 --- a/trustpoint/templates/management/backend_configuration.html +++ b/trustpoint/templates/management/backend_configuration.html @@ -342,9 +342,9 @@

{% trans 'AutoGenPKI Choices' %}

- {% if supported_auto_gen_pki_algorithms %} + {% if supported_auto_gen_pki_key_types %} - {{ supported_auto_gen_pki_algorithms|length }} + {{ supported_auto_gen_pki_key_types|length }} {% endif %} @@ -353,12 +353,12 @@

- {% if supported_auto_gen_pki_algorithms %} + {% if supported_auto_gen_pki_key_types %}
- {% for algorithm in supported_auto_gen_pki_algorithms %} + {% for key_type, key_type_label in supported_auto_gen_pki_key_types %} - {{ algorithm.label }} + {{ key_type_label }} {% endfor %}
@@ -366,7 +366,7 @@

{% else %}
- {% trans 'Auto-generated PKI cannot be enabled until the active backend supports at least one allowed key algorithm.' %} + {% trans 'Auto-generated PKI cannot be generated until the active backend supports at least one key type.' %}
{% endif %} diff --git a/trustpoint/templates/management/includes/security_configuration.html b/trustpoint/templates/management/includes/security_configuration.html index 61abc2fc7..838b7aa7f 100644 --- a/trustpoint/templates/management/includes/security_configuration.html +++ b/trustpoint/templates/management/includes/security_configuration.html @@ -186,7 +186,6 @@

{# Footer #} {# ============================================================ #} diff --git a/trustpoint/templates/pki/issuing_cas/add/method_select.html b/trustpoint/templates/pki/issuing_cas/add/method_select.html index 0d51792e1..f7e8997b2 100644 --- a/trustpoint/templates/pki/issuing_cas/add/method_select.html +++ b/trustpoint/templates/pki/issuing_cas/add/method_select.html @@ -301,4 +301,43 @@

+ +
+
+

{% trans 'Auto-generated PKI' %}

+

+ {% trans 'Generate a local Root CA, Issuing CA and Domain managed by Trustpoint.' %} +

+
+
+ {% if active_auto_gen_pki %} +
+ {% trans 'An auto-generated PKI is already active.' %} + + {% trans 'View the active Issuing CA' %} + +
+ {% elif not auto_gen_pki_allowed %} +
+ {% trans 'Auto-generated PKI creation is disabled in the Security Settings.' %} + + {% trans 'Security Settings' %} + +
+ {% endif %} + +
+ {% csrf_token %} +
+ + {{ auto_gen_pki_form.key_type }} +
+ +
+
+
{% endblock content %} \ No newline at end of file