diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..fcf3807b8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,26 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "External TLS CSR focused tests", + "type": "shell", + "command": "uv run pytest trustpoint/pki/tests/test_external_csr.py trustpoint/request/tests/test_operation_processors_csr.py trustpoint/pki/tests/test_util_cert_req_converter.py -q -o md_report=false", + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "External TLS wizard tests", + "type": "shell", + "command": "uv run pytest trustpoint/management/tests/test_views/test_tls_external_csr.py -q -o md_report=false", + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "External TLS wizard first failure", + "type": "shell", + "command": "uv run pytest trustpoint/management/tests/test_views/test_tls_external_csr.py -x -q -o md_report=false", + "isBackground": false, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/trustpoint/crypto/adapters/software/backend.py b/trustpoint/crypto/adapters/software/backend.py index 0a4ae8205..69d9a3c5c 100644 --- a/trustpoint/crypto/adapters/software/backend.py +++ b/trustpoint/crypto/adapters/software/backend.py @@ -18,6 +18,7 @@ from crypto.domain.errors import ( KeyNotFoundError, MechanismUnsupportedError, + ProviderConfigurationError, ProviderUnavailableError, UnsupportedKeySpecError, ) @@ -110,6 +111,17 @@ def generate_managed_key(self, *, alias: str, key_spec: KeySpec, policy: KeyPoli provider_label=alias, ) + def export_private_key_pkcs8(self, key: SoftwareManagedKeyBinding) -> bytes: + """Export unencrypted PKCS#8 PEM after the application checks the key policy.""" + if not self._profile.allow_exportable_private_keys: + msg = 'The software provider does not allow private-key export.' + raise ProviderConfigurationError(msg) + return self._load_private_key(key).private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + def verify_managed_key(self, key: SoftwareManagedKeyBinding) -> SoftwareManagedKeyVerification: """Verify that a software-managed binding still decrypts to the expected key.""" try: diff --git a/trustpoint/crypto/application/service.py b/trustpoint/crypto/application/service.py index 8ce0ab1eb..8cca3651d 100644 --- a/trustpoint/crypto/application/service.py +++ b/trustpoint/crypto/application/service.py @@ -17,6 +17,7 @@ from crypto.adapters.pkcs11.bindings import Pkcs11ManagedKeyBinding from crypto.adapters.protected_import.bindings import ProtectedImportManagedKeyBinding from crypto.adapters.rest.bindings import RestManagedKeyBinding +from crypto.adapters.software.backend import SoftwareBackend from crypto.adapters.software.bindings import SoftwareManagedKeyBinding from crypto.application.audit import ( audit_crypto_backend_operation, @@ -32,7 +33,13 @@ encrypt_imported_private_key, imported_key_algorithm, ) -from crypto.domain.errors import CryptoError, KeyNotFoundError, ProviderConfigurationError, UnsupportedKeySpecError +from crypto.domain.errors import ( + CryptoError, + KeyNotFoundError, + ProviderConfigurationError, + ProviderOperationNotImplementedError, + UnsupportedKeySpecError, +) from crypto.domain.refs import ManagedKeyRef, ManagedKeyVerification, ManagedKeyVerificationStatus from crypto.models import BackendKind, CryptoManagedKeyModel, CryptoProviderProfileModel from crypto.repositories import CryptoManagedKeyRepository, CryptoProviderProfileRepository, ManagedKeyBinding @@ -348,6 +355,28 @@ def get_public_key(self, key: ManagedKeyRef) -> SupportedPublicKey: ) return public_key + def export_private_key_pkcs8(self, key: ManagedKeyRef) -> bytes: + """Export an explicitly extractable software key as unencrypted PKCS#8 PEM.""" + managed_key = self._load_managed_key(key) + if managed_key.policy_snapshot.get('extractable') is not True: + msg = 'The managed private key is not extractable.' + raise ProviderConfigurationError(msg) + if managed_key.provider_profile.backend_kind != BackendKind.SOFTWARE: + msg = 'Managed private-key export is only supported for software backend keys, not PKCS#11 or remote keys.' + raise ProviderOperationNotImplementedError(msg) + binding = self._managed_key_repository.build_backend_binding(managed_key) + if not isinstance(binding, SoftwareManagedKeyBinding): + msg = 'Managed private-key export requires an encrypted software backend binding.' + raise ProviderOperationNotImplementedError(msg) + adapter = self._build_adapter(managed_key.provider_profile) + try: + if not isinstance(adapter, SoftwareBackend): + msg = 'The configured software adapter does not support managed private-key export.' + raise ProviderOperationNotImplementedError(msg) + return adapter.export_private_key_pkcs8(binding) + finally: + adapter.close() + def sign(self, *, key: ManagedKeyRef, data: bytes, request: SignRequest) -> bytes: """Sign bytes with a managed key.""" managed_key = self._load_managed_key(key) diff --git a/trustpoint/crypto/tests/test_application_backend.py b/trustpoint/crypto/tests/test_application_backend.py index 4a1e3a045..b9d2d5208 100644 --- a/trustpoint/crypto/tests/test_application_backend.py +++ b/trustpoint/crypto/tests/test_application_backend.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa from appsecrets.models import AppSecretBackendKind, AppSecretBackendModel @@ -17,9 +17,10 @@ Pkcs11ManagedKeyBinding, Pkcs11ManagedKeyVerification, ) +from crypto.adapters.software.backend import SoftwareBackend from crypto.application.service import TrustpointCryptoBackend from crypto.domain.algorithms import KeyAlgorithm -from crypto.domain.errors import ProviderConfigurationError +from crypto.domain.errors import AuthenticationError, ProviderConfigurationError, ProviderOperationNotImplementedError from crypto.domain.policies import KeyPolicy, SigningExecutionMode from crypto.domain.refs import ManagedKeyVerificationStatus from crypto.domain.specs import RsaKeySpec, SignRequest @@ -36,6 +37,141 @@ SoftwareKeyEncryptionSource, ) from management.models import AuditLog, LoggingConfig, SecurityConfig +from pki.models.credential import CredentialModel + + +@pytest.fixture +def export_profile(monkeypatch: pytest.MonkeyPatch) -> CryptoProviderProfileModel: + """Configure the real software backend with protected encryption material.""" + monkeypatch.setenv('TRUSTPOINT_TEST_TLS_KEY_SECRET', 'test-only-encryption-material') + profile = CryptoProviderProfileModel.objects.create( + name='tls-export', backend_kind=BackendKind.SOFTWARE, active=True, + ) + CryptoProviderSoftwareConfigModel.objects.create( + profile=profile, + encryption_source=SoftwareKeyEncryptionSource.ENV, + encryption_source_ref='TRUSTPOINT_TEST_TLS_KEY_SECRET', + allow_exportable_private_keys=True, + ) + return profile + + +@pytest.mark.django_db +def test_export_private_key_pkcs8_matches_generated_key(export_profile: CryptoProviderProfileModel) -> None: + """Export decrypts the persisted key rather than generating a replacement.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/server', key_spec=RsaKeySpec(key_size=2048), + policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH), + ) + exported = backend.export_private_key_pkcs8(key) + private_key = serialization.load_pem_private_key(exported, password=None) + assert exported.startswith(b'-----BEGIN PRIVATE KEY-----') + assert private_key.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, + ) == backend.get_public_key(key).public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, + ) + managed_key = CryptoManagedKeyModel.objects.get(pk=key.id) + assert managed_key.provider_profile_id == export_profile.pk + assert managed_key.policy_snapshot['extractable'] is True + assert bytes(managed_key.software_binding.encrypted_private_key_pkcs8_der) != exported + assert CryptoManagedKeyModel.objects.count() == 1 + + +@pytest.mark.django_db +@pytest.mark.usefixtures('export_profile') +def test_export_private_key_pkcs8_rejects_nonextractable_key() -> None: + """The default CA policy stays non-exportable even on an export-enabled provider.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='ca/root', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy.managed_signing_key(), + ) + with pytest.raises(ProviderConfigurationError, match='not extractable'): + backend.export_private_key_pkcs8(key) + + +@pytest.mark.django_db +def test_export_private_key_pkcs8_rejects_provider_policy(export_profile: CryptoProviderProfileModel) -> None: + """A key opt-in cannot override the provider's export restriction.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/server', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + config = export_profile.software_config + config.allow_exportable_private_keys = False + config.save(update_fields=['allow_exportable_private_keys']) + with pytest.raises(ProviderConfigurationError, match='does not allow private-key export'): + backend.export_private_key_pkcs8(key) + + +@pytest.mark.django_db +@pytest.mark.parametrize('backend_kind', [BackendKind.PKCS11, BackendKind.REST]) +def test_export_private_key_pkcs8_rejects_unsupported_backend(backend_kind: str) -> None: + """Non-software providers have no safe deployment export contract.""" + profile = CryptoProviderProfileModel.objects.create(name='unsupported-export', backend_kind=backend_kind) + managed_key = CryptoManagedKeyModel.objects.create( + alias='tls/unsupported', provider_profile=profile, algorithm='rsa', + public_key_fingerprint_sha256='a' * 64, policy_snapshot={'extractable': True}, + ) + with pytest.raises(ProviderOperationNotImplementedError, match='only supported for software'): + TrustpointCryptoBackend().export_private_key_pkcs8(managed_key.to_managed_key_ref()) + + +@pytest.mark.django_db +@pytest.mark.usefixtures('export_profile') +def test_managed_tls_credential_serializer_exports_without_regenerating() -> None: + """The activation serializer exports the persisted TLS key, but its wrapper cannot.""" + backend = TrustpointCryptoBackend() + with patch.object( + SoftwareBackend, 'generate_managed_key', autospec=True, side_effect=SoftwareBackend.generate_managed_key, + ) as generate: + key = backend.generate_managed_key( + alias='tls/credential', key_spec=RsaKeySpec(key_size=2048), + policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH), + ) + credential = CredentialModel.objects.create( + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + managed_private_key_id=key.id, + ) + credential.refresh_from_db() + assert credential.private_key == '' + assert credential.get_private_key_serializer().as_pkcs8_pem() == backend.export_private_key_pkcs8(key) + assert generate.call_count == 1 + with pytest.raises(NotImplementedError, match='cannot be exported'): + credential.get_private_key().private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize('credential_type', [ + CredentialModel.CredentialTypeChoice.ROOT_CA, CredentialModel.CredentialTypeChoice.ISSUING_CA, +]) +@pytest.mark.usefixtures('export_profile') +def test_ca_credential_serializer_does_not_use_tls_export(credential_type: int) -> None: + """Even an extractable key does not grant CA credentials the TLS serializer path.""" + key = TrustpointCryptoBackend().generate_managed_key( + alias='ca/credential', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + credential = CredentialModel.objects.create(credential_type=credential_type, managed_private_key_id=key.id) + with patch.object(TrustpointCryptoBackend, 'export_private_key_pkcs8') as export: + with pytest.raises((RuntimeError, NotImplementedError), match='cannot be exported'): + credential.get_private_key_serializer().as_pkcs8_pem() + export.assert_not_called() + + +@pytest.mark.django_db +@pytest.mark.usefixtures('export_profile') +def test_export_private_key_pkcs8_requires_encryption_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """Export still uses the configured secret resolver to unseal encrypted material.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/protected', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + monkeypatch.delenv('TRUSTPOINT_TEST_TLS_KEY_SECRET') + with pytest.raises(AuthenticationError, match='is missing'): + backend.export_private_key_pkcs8(key) @dataclass diff --git a/trustpoint/crypto/tests/test_tls_export.py b/trustpoint/crypto/tests/test_tls_export.py new file mode 100644 index 000000000..a0c87e838 --- /dev/null +++ b/trustpoint/crypto/tests/test_tls_export.py @@ -0,0 +1,154 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Tests for explicitly exportable managed TLS deployment keys.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from cryptography.hazmat.primitives import serialization + +from crypto.adapters.software.backend import SoftwareBackend +from crypto.application.service import TrustpointCryptoBackend +from crypto.domain.errors import AuthenticationError, ProviderConfigurationError, ProviderOperationNotImplementedError +from crypto.domain.policies import KeyPolicy, SigningExecutionMode +from crypto.domain.specs import RsaKeySpec +from crypto.models import ( + BackendKind, + CryptoManagedKeyModel, + CryptoProviderProfileModel, + CryptoProviderSoftwareConfigModel, + SoftwareKeyEncryptionSource, +) +from pki.models.credential import CredentialModel + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def export_profile(monkeypatch: pytest.MonkeyPatch) -> CryptoProviderProfileModel: + """Configure the real software backend with protected encryption material.""" + monkeypatch.setenv('TRUSTPOINT_TEST_TLS_KEY_SECRET', 'test-only-encryption-material') + profile = CryptoProviderProfileModel.objects.create( + name='tls-export', backend_kind=BackendKind.SOFTWARE, active=True, + ) + CryptoProviderSoftwareConfigModel.objects.create( + profile=profile, + encryption_source=SoftwareKeyEncryptionSource.ENV, + encryption_source_ref='TRUSTPOINT_TEST_TLS_KEY_SECRET', + allow_exportable_private_keys=True, + ) + return profile + + +def test_export_private_key_pkcs8_matches_generated_key(export_profile: CryptoProviderProfileModel) -> None: + """Export decrypts the persisted key rather than generating a replacement.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/server', key_spec=RsaKeySpec(key_size=2048), + policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH), + ) + exported = backend.export_private_key_pkcs8(key) + private_key = serialization.load_pem_private_key(exported, password=None) + assert exported.startswith(b'-----BEGIN PRIVATE KEY-----') + assert private_key.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, + ) == backend.get_public_key(key).public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, + ) + managed_key = CryptoManagedKeyModel.objects.get(pk=key.id) + assert managed_key.provider_profile_id == export_profile.pk + assert managed_key.policy_snapshot['extractable'] is True + assert bytes(managed_key.software_binding.encrypted_private_key_pkcs8_der) != exported + assert CryptoManagedKeyModel.objects.count() == 1 + + +@pytest.mark.usefixtures('export_profile') +def test_export_private_key_pkcs8_rejects_nonextractable_key() -> None: + """The default CA policy stays non-exportable even on an export-enabled provider.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='ca/root', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy.managed_signing_key(), + ) + with pytest.raises(ProviderConfigurationError, match='not extractable'): + backend.export_private_key_pkcs8(key) + + +def test_export_private_key_pkcs8_rejects_provider_policy(export_profile: CryptoProviderProfileModel) -> None: + """A key opt-in cannot override the provider's export restriction.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/server', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + config = export_profile.software_config + config.allow_exportable_private_keys = False + config.save(update_fields=['allow_exportable_private_keys']) + with pytest.raises(ProviderConfigurationError, match='does not allow private-key export'): + backend.export_private_key_pkcs8(key) + + +@pytest.mark.parametrize('backend_kind', [BackendKind.PKCS11, BackendKind.REST]) +def test_export_private_key_pkcs8_rejects_unsupported_backend(backend_kind: str) -> None: + """Non-software providers have no safe deployment export contract.""" + profile = CryptoProviderProfileModel.objects.create(name='unsupported-export', backend_kind=backend_kind) + managed_key = CryptoManagedKeyModel.objects.create( + alias='tls/unsupported', provider_profile=profile, algorithm='rsa', + public_key_fingerprint_sha256='a' * 64, policy_snapshot={'extractable': True}, + ) + with pytest.raises(ProviderOperationNotImplementedError, match='only supported for software'): + TrustpointCryptoBackend().export_private_key_pkcs8(managed_key.to_managed_key_ref()) + + +@pytest.mark.usefixtures('export_profile') +def test_managed_tls_credential_serializer_exports_without_regenerating() -> None: + """The activation serializer exports the persisted TLS key, but its wrapper cannot.""" + backend = TrustpointCryptoBackend() + with patch.object( + SoftwareBackend, 'generate_managed_key', autospec=True, side_effect=SoftwareBackend.generate_managed_key, + ) as generate: + key = backend.generate_managed_key( + alias='tls/credential', key_spec=RsaKeySpec(key_size=2048), + policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH), + ) + credential = CredentialModel.objects.create( + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + managed_private_key_id=key.id, + ) + credential.refresh_from_db() + assert credential.private_key == '' + assert credential.get_private_key_serializer().as_pkcs8_pem() == backend.export_private_key_pkcs8(key) + assert generate.call_count == 1 + with pytest.raises(NotImplementedError, match='cannot be exported'): + credential.get_private_key().private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), + ) + + +@pytest.mark.parametrize('credential_type', [ + CredentialModel.CredentialTypeChoice.ROOT_CA, CredentialModel.CredentialTypeChoice.ISSUING_CA, +]) +@pytest.mark.usefixtures('export_profile') +def test_ca_credential_serializer_does_not_use_tls_export(credential_type: int) -> None: + """Even an extractable key does not grant CA credentials the TLS serializer path.""" + key = TrustpointCryptoBackend().generate_managed_key( + alias='ca/credential', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + credential = CredentialModel.objects.create(credential_type=credential_type, managed_private_key_id=key.id) + with patch.object(TrustpointCryptoBackend, 'export_private_key_pkcs8') as export: + with pytest.raises((RuntimeError, NotImplementedError), match='cannot be exported'): + credential.get_private_key_serializer().as_pkcs8_pem() + export.assert_not_called() + + +@pytest.mark.usefixtures('export_profile') +def test_export_private_key_pkcs8_requires_encryption_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """Export still uses the configured secret resolver to unseal encrypted material.""" + backend = TrustpointCryptoBackend() + key = backend.generate_managed_key( + alias='tls/protected', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True), + ) + monkeypatch.delenv('TRUSTPOINT_TEST_TLS_KEY_SECRET') + with pytest.raises(AuthenticationError, match='is missing'): + backend.export_private_key_pkcs8(key) diff --git a/trustpoint/management/forms.py b/trustpoint/management/forms.py index d32e36092..fe65d20bc 100644 --- a/trustpoint/management/forms.py +++ b/trustpoint/management/forms.py @@ -13,7 +13,9 @@ from crispy_bootstrap5.bootstrap5 import Field from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Fieldset, Layout +from cryptography import x509 from cryptography.x509 import Certificate +from cryptography.x509.verification import VerificationError from django import forms from django.core.exceptions import ValidationError from django.core.validators import FileExtensionValidator, RegexValidator @@ -42,6 +44,9 @@ from management.security.features import AutoGenPkiFeature, SecurityFeature from onboarding.enums import NoOnboardingPkiProtocol, OnboardingProtocol from pki.models import CredentialModel +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 @@ -55,6 +60,92 @@ MAX_PASSWORD_LIST_BYTES = 10 * 1024 * 1024 +class TlsExternalCsrKeyForm(forms.Form): + """Select a supported management TLS key type.""" + + key_type = forms.ChoiceField(label=_('Key Type'), initial='ECC-SECP256R1') + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Limit offerings to supported RSA and TLS elliptic curves.""" + super().__init__(*args, **kwargs) + choices = supported_key_type_choices([ + ('RSA-2048', 'RSA 2048'), ('RSA-3072', 'RSA 3072'), ('RSA-4096', 'RSA 4096'), + ('ECC-SECP256R1', 'ECC SECP256R1'), ('ECC-SECP384R1', 'ECC SECP384R1'), + ]) + key_type_field = cast('forms.ChoiceField', self.fields['key_type']) + key_type_field.choices = choices + if choices and 'ECC-SECP256R1' not in dict(choices): + key_type_field.initial = choices[0][0] + + +class TlsExternalCsrTruststoreForm(forms.Form): + """Select the external PKI chain without adding a credential foreign key.""" + + truststore = forms.ModelChoiceField(queryset=TruststoreModel.objects.all(), label=_('Trust Store')) + + +class TlsExternalCsrCertificateForm(forms.Form): + """Validate a single externally issued TLS leaf against its request and chain.""" + + certificate = forms.FileField(label=_('Issued TLS Certificate (.pem, .der, .cer)')) + + def __init__( + self, *args: Any, credential: CredentialModel, csr: x509.CertificateSigningRequest, + truststore: TruststoreModel, **kwargs: Any, + ) -> None: + """Bind validation to the session's pending key and CSR.""" + super().__init__(*args, **kwargs) + self.credential = credential + self.csr = csr + self.truststore = truststore + self.chain: list[x509.Certificate] = [] + + def clean_certificate(self) -> x509.Certificate: + """Validate identity and perform full TLS path validation.""" + uploaded = self.cleaned_data['certificate'] + if uploaded.size > 64 * 1024: + raise forms.ValidationError(_('Certificate file is too large, max. 64 KiB.')) + certificate = parse_single_certificate(uploaded.read()) + try: + constraints = certificate.extensions.get_extension_for_class(x509.BasicConstraints).value + except x509.ExtensionNotFound: + constraints = None + if constraints is not None and constraints.ca: + raise forms.ValidationError(_('The issued TLS certificate must not be a CA certificate.')) + if not certificate_matches_credential(certificate, self.credential): + raise forms.ValidationError(_('The uploaded certificate does not match the generated CSR key.')) + if certificate.subject != self.csr.subject: + raise forms.ValidationError(_('The certificate subject does not match the generated CSR.')) + expected_san = self.san_names(self.csr) + actual_san = self.san_names(certificate) + if sorted(map(repr, expected_san)) != sorted(map(repr, actual_san)): + raise forms.ValidationError(_('The certificate SANs must exactly match the generated CSR.')) + identity = next( + (str(name.value) for name in expected_san if isinstance(name, (x509.DNSName, x509.IPAddress))), None, + ) + if identity is None: + raise forms.ValidationError(_('A DNS name or IP address SAN is required for management TLS.')) + certificates = self.truststore.get_certificate_collection_serializer().as_crypto() + roots = [cert for cert in certificates if cert.subject == cert.issuer] + intermediates = [cert for cert in certificates if cert.subject != cert.issuer] + try: + self.chain = CertificateVerifier.verify_server_cert(certificate, identity, roots, intermediates) + except (VerificationError, ValueError) as exception: + raise forms.ValidationError( + _('The certificate cannot be verified with the selected trust store: %(error)s'), + params={'error': str(exception)}, + ) from exception + return certificate + + @staticmethod + def san_names(certificate: x509.Certificate | x509.CertificateSigningRequest) -> list[x509.GeneralName]: + """Return the full requested identity, including non-TLS SAN types.""" + try: + return list(certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName).value) + except x509.ExtensionNotFound: + return [] + + class AccountSecurityConfigForm(forms.ModelForm[AccountSecurityConfig]): """Form for the global account-security policy.""" diff --git a/trustpoint/management/tests/test_views/test_tls_external_csr.py b/trustpoint/management/tests/test_views/test_tls_external_csr.py new file mode 100644 index 000000000..a5251ddf7 --- /dev/null +++ b/trustpoint/management/tests/test_views/test_tls_external_csr.py @@ -0,0 +1,424 @@ + +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Management external TLS CSR wizard integration and security tests.""" + +import json +from datetime import UTC, datetime, timedelta +from ipaddress import ip_address +from unittest.mock import Mock, patch + +import pytest +from bs4 import BeautifulSoup +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.http import Http404 +from django.template.loader import render_to_string +from django.test import RequestFactory +from django.urls import resolve, reverse + +from management.forms import TlsExternalCsrKeyForm +from management.models import SecurityConfig +from pki.forms.truststores import TruststoreAddForm +from pki.models import CertificateModel, CredentialModel +from pki.models.cert_profile import CertificateProfileModel +from pki.models.credential import CertificateChainOrderModel, PrimaryCredentialCertificate +from pki.models.truststore import ActiveTrustpointTlsServerCredentialModel, TruststoreModel +from users.permissions import AppPermissions + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def profiles(): + call_command('create_default_cert_profiles') + SecurityConfig.objects.create(security_mode=True, rsa_minimum_key_size=2048) + + +class Wizard: + def __init__(self): + self.session = {} + self.user = Mock() + self.user.has_perm.return_value = True + self.factory = RequestFactory() + + def request(self, name, pk=None, method='get', data=None, query=''): + url = reverse(f'management:{name}', kwargs={'pk': pk} if pk is not None else {}) + query + request = getattr(self.factory, method)(url, data=data or {}) + request.user = self.user + request.session = self.session + match = resolve(url.split('?')[0]) + response = match.func(request, **match.kwargs) + if hasattr(response, 'render'): + response.render() + return response + + def start(self, key_type='ECC-SECP256R1'): + response = self.request('tls-external-csr-key', method='post', data={'key_type': key_type}) + assert response.status_code == 302 + pk = resolve(response.url).kwargs['pk'] + return CredentialModel.objects.get(pk=pk) + + def content(self, credential, truststore, data=None, profile=None): + response = self.request('tls-external-csr-truststore', credential.pk, 'post', {'truststore': truststore.pk}) + assert response.status_code == 302 + values = { + 'common_name': 'server.example', 'organization_name': 'Example', + 'organizational_unit_name': 'Operations', 'country_name': 'DE', + 'state_or_province_name': 'BW', 'locality_name': 'Stuttgart', + 'email_address': 'ops@example.com', 'dns_names': 'server.example, second.example', + 'ip_addresses': '127.0.0.1, ::1', 'rfc822_names': 'ops@example.com', + 'uris': 'urn:example:server', 'days': 10, + } + values.update(data or {}) + if profile is not None: + values['cert_profile_pk'] = profile.pk + response = self.request('tls-external-csr-content', credential.pk, 'post', values) + assert response.status_code == 302, response.context_data['form'].errors if response.status_code != 302 else '' + return x509.load_pem_x509_csr(self.session[f'tls_external_csr_{credential.pk}']['csr_pem'].encode()) + + +@pytest.fixture +def wizard(): + return Wizard() + + +def ca_certificate(key, subject, issuer_key=None, issuer=None, path_length=None, expired=False): + now = datetime.now(UTC) + signing_key = issuer_key or key + return ( + x509.CertificateBuilder().subject_name(subject).issuer_name(issuer or subject) + .public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=30)) + .not_valid_after(now + timedelta(days=-1 if expired else 365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=path_length), critical=True) + .add_extension(x509.KeyUsage(False, False, False, False, False, True, True, False, False), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(signing_key.public_key()), critical=False) + .sign(signing_key, hashes.SHA256()) + ) + + +@pytest.fixture +def pki_chain(): + root_key = ec.generate_private_key(ec.SECP256R1()) + root_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, 'External Root')]) + root = ca_certificate(root_key, root_name) + issuer_key = ec.generate_private_key(ec.SECP256R1()) + issuer_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, 'External Issuer')]) + issuer = ca_certificate(issuer_key, issuer_name, root_key, root_name) + truststore = TruststoreAddForm.save_trust_store('external-tls', TruststoreModel.IntendedUsage.TLS, [issuer, root]) + return root, issuer, issuer_key, truststore + + +def issue(csr, issuer, issuer_key, change=None): + change = change or {} + now = datetime.now(UTC) + builder = ( + x509.CertificateBuilder().subject_name(change.get('subject', csr.subject)).issuer_name(issuer.subject) + .public_key(change.get('key', csr.public_key())).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=30)).not_valid_after(now + timedelta(days=change.get('days', 10))) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), critical=False) + ) + for extension in csr.extensions: + if extension.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME and change.get('missing_san'): + continue + value = extension.value + if extension.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME and 'san' in change: + value = change['san'] + if extension.oid == x509.ExtensionOID.BASIC_CONSTRAINTS and change.get('ca'): + value = x509.BasicConstraints(ca=True, path_length=None) + critical = extension.critical + if extension.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + critical = change.get('critical_san', not bool(csr.subject)) + builder = builder.add_extension(value, critical=critical) + return builder.sign(issuer_key, hashes.SHA256()) + + +def upload(wizard, credential, data): + return wizard.request('tls-external-csr', credential.pk, 'post', { + 'certificate': SimpleUploadedFile('issued.cer', data), + }) + + +def test_method_selection_cards_share_existing_style(): + page = BeautifulSoup(render_to_string('management/tls/method_select.html'), 'html.parser') + for route in ( + 'tls-add-file_import-pkcs12', 'tls-add-file_import-separate_files', + 'tls-generate', 'tls-external-csr-key', + ): + option = page.select_one(f'a[href="{reverse(f"management:{route}")}"]') + assert option.select_one('.card.h-100.shadow-sm') is not None + assert option.select_one('.card-body.d-flex.flex-column') is not None + assert option.select_one('p.text-muted') is not None + assert option.select_one('.mt-auto .btn.btn-primary') is not None + + +def test_key_default_choices_and_capability_filter(wizard): + response = wizard.request('tls-external-csr-key') + page = BeautifulSoup(response.content, 'html.parser') + assert page.select_one('h1').get_text(strip=True) == 'Add New TLS Certificate' + assert page.select_one('h2').get_text(strip=True) == 'External PKI' + assert page.select_one('.alert.alert-info.mb-4') is not None + assert page.select_one('#tls-external-csr-key-form #div_id_key_type') is not None + assert page.select_one('.tp-kvp-list') is None + back = page.select_one('.card-footer a.btn-secondary') + assert back['href'] == reverse('management:tls-add-method_select') + assert back.get_text(strip=True) == 'Back' + assert page.select_one('.card-footer button[form="tls-external-csr-key-form"]') is not None + field = response.context_data['form'].fields['key_type'] + assert field.initial == 'ECC-SECP256R1' + assert {value for value, label in field.choices} == { + 'RSA-2048', 'RSA-3072', 'RSA-4096', 'ECC-SECP256R1', 'ECC-SECP384R1', + } + with patch('pki.services.key_generation.get_active_backend_capability_report') as report: + report.return_value.supports_key_spec.side_effect = lambda spec: getattr(spec, 'key_size', None) == 3072 + assert list(TlsExternalCsrKeyForm().fields['key_type'].choices) == [('RSA-3072', 'RSA 3072')] + response = wizard.request('tls-external-csr-key', method='post', data={'key_type': 'ECC-SECP256R1'}) + assert response.status_code == 200 + assert CredentialModel.objects.count() == 0 + + +@pytest.mark.parametrize('key_type', ['ECC-SECP256R1', 'RSA-2048']) +def test_pending_key_and_csr_extensions(wizard, pki_chain, key_type): + credential = wizard.start(key_type) + assert credential.certificate_id is None + assert credential.private_key == '' + assert credential.managed_private_key is not None + assert credential.managed_private_key.policy_snapshot['extractable'] is True + csr = wizard.content(credential, pki_chain[3]) + assert csr.is_signature_valid + assert csr.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) == ( + credential.get_private_key().public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + ) + for extension in (x509.BasicConstraints, x509.KeyUsage, x509.ExtendedKeyUsage, x509.SubjectAlternativeName): + csr.extensions.get_extension_for_class(extension) + assert csr.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value == x509.ExtendedKeyUsage([ + x509.ExtendedKeyUsageOID.SERVER_AUTH, + ]) + assert csr.subject.get_attributes_for_oid(x509.NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value == 'Operations' + state = wizard.session[f'tls_external_csr_{credential.pk}'] + assert json.loads(json.dumps(state)) == state + overview = wizard.request('tls-external-csr', credential.pk) + assert 'TLS Server' in overview.content.decode() + assert '/truststore/', tls_external_csr.TlsExternalCsrTruststoreView.as_view(), + name='tls-external-csr-truststore'), + path('tls/add/external-csr//content/', tls_external_csr.TlsExternalCsrContentView.as_view(), + name='tls-external-csr-content'), + path('tls/add/external-csr//', tls_external_csr.TlsExternalCsrView.as_view(), name='tls-external-csr'), path('tls/add/method-select/', tls.TlsAddMethodSelectView.as_view(), name='tls-add-method_select', ), diff --git a/trustpoint/management/views/tls_external_csr.py b/trustpoint/management/views/tls_external_csr.py new file mode 100644 index 000000000..4a18a9588 --- /dev/null +++ b/trustpoint/management/views/tls_external_csr.py @@ -0,0 +1,248 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Session-scoped external PKI wizard for management TLS certificates.""" + +from __future__ import annotations + +import json +from typing import Any, TypeVar, cast +from uuid import uuid4 + +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.forms import Form +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView + +from crypto.domain.errors import CryptoError +from crypto.domain.policies import KeyPolicy, SigningExecutionMode +from management.forms import ( + TlsExternalCsrCertificateForm, + TlsExternalCsrKeyForm, + TlsExternalCsrTruststoreForm, +) +from pki.forms import CertificateIssuanceForm, TruststoreAddForm +from pki.models import CredentialModel +from pki.models.cert_profile import CertificateProfileModel +from pki.models.truststore import TruststoreModel +from pki.services.external_csr import build_external_csr, csr_download_response, finalize_external_certificate +from pki.services.key_generation import generate_pending_credential +from pki.util.cert_profile import ProfileValidationError +from users.permissions import AppPermissions + +TLS_CSR_SCOPE = 'management-tls-external-csr' +WizardForm = TypeVar('WizardForm', bound=Form) + + +class TlsExternalCsrWizardView(FormView[WizardForm]): + """Enforce permission and session ownership before every wizard operation.""" + + template_name = 'management/tls/external_csr_form.html' + pending_required = True + credential: CredentialModel + state: dict[str, Any] + session_key: str + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Reject foreign sessions and completed credentials before processing data.""" + if not request.user.has_perm(AppPermissions.MANAGE_TLS_WEBSERVER_CONFIGURATION): + raise PermissionDenied + if self.pending_required: + self.session_key = f'tls_external_csr_{kwargs["pk"]}' + self.state = request.session.get(self.session_key, {}) + scope = self.state.get('scope') + if not scope: + raise Http404 + self.credential = get_object_or_404( + CredentialModel, pk=kwargs['pk'], certificate__isnull=True, + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + managed_private_key__alias=f'{TLS_CSR_SCOPE}/{scope}', + ) + return cast('HttpResponse', super().dispatch(request, *args, **kwargs)) + + def save_state(self) -> None: + """Persist only the current credential's JSON-safe state.""" + self.request.session[self.session_key] = json.loads(json.dumps(self.state, cls=DjangoJSONEncoder)) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Apply the existing TLS navigation context.""" + return super().get_context_data(**kwargs) | { + 'page_category': 'management', 'page_name': 'tls', + 'credential': getattr(self, 'credential', None), + } + + +class TlsExternalCsrKeyView(TlsExternalCsrWizardView[TlsExternalCsrKeyForm]): + """Immediately create one key-only TLS credential.""" + + pending_required = False + template_name = 'management/tls/external_csr_key.html' + form_class = TlsExternalCsrKeyForm + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Describe the key-generation step.""" + return super().get_context_data(**kwargs) | { + 'title': _('Generate TLS Key'), 'submit_label': _('Generate Key'), + } + + def form_valid(self, form: TlsExternalCsrKeyForm) -> HttpResponse: + """Create the managed key with the deployment policy, without exporting it.""" + scope = uuid4().hex + try: + credential = generate_pending_credential( + alias=f'{TLS_CSR_SCOPE}/{scope}', key_type=form.cleaned_data['key_type'], + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH), + ) + except (CryptoError, ValidationError) as exception: + form.add_error(None, str(exception)) + return self.form_invalid(form) + self.request.session[f'tls_external_csr_{credential.pk}'] = { + 'scope': scope, 'key_type': form.cleaned_data['key_type'], + } + return redirect('management:tls-external-csr-truststore', pk=credential.pk) + + +class TlsExternalCsrTruststoreView(TlsExternalCsrWizardView[TlsExternalCsrTruststoreForm]): + """Select or import the external PKI chain using the existing import form.""" + + form_class = TlsExternalCsrTruststoreForm + + def get_initial(self) -> dict[str, Any]: + """Preselect a newly imported or previously selected truststore.""" + return {'truststore': self.request.GET.get('truststore_id') or self.state.get('truststore_pk')} + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Include the truststore import modal.""" + context = super().get_context_data(**kwargs) + context.setdefault( + 'import_form', TruststoreAddForm(initial={'intended_usage': TruststoreModel.IntendedUsage.TLS}), + ) + return context | {'title': _('Associate Trust Store'), 'submit_label': _('Associate Trust Store')} + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Route modal submissions through the existing truststore import service.""" + if 'trust_store_file' in request.FILES or request.POST.get('import_truststore'): + import_form = TruststoreAddForm(request.POST, request.FILES) + if import_form.is_valid(): + truststore = import_form.cleaned_data['truststore'] + return redirect( + reverse('management:tls-external-csr-truststore', kwargs={'pk': self.credential.pk}) + + f'?truststore_id={truststore.pk}' + ) + return self.render_to_response(self.get_context_data(import_form=import_form)) + return super().post(request, *args, **kwargs) + + def form_valid(self, form: TlsExternalCsrTruststoreForm) -> HttpResponse: + """Store the selection only in this credential's session namespace.""" + self.state['truststore_pk'] = form.cleaned_data['truststore'].pk + self.save_state() + return redirect('management:tls-external-csr-content', pk=self.credential.pk) + + +class TlsExternalCsrContentView(TlsExternalCsrWizardView[CertificateIssuanceForm]): + """Define subject and SANs using an APPLICATION certificate profile.""" + + form_class = CertificateIssuanceForm + profile: CertificateProfileModel + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Resolve explicit profiles without silently accepting non-application profiles.""" + if not request.user.has_perm(AppPermissions.MANAGE_TLS_WEBSERVER_CONFIGURATION): + raise PermissionDenied + profiles = CertificateProfileModel.objects.filter( + credential_type=CertificateProfileModel.ProfileCredentialType.APPLICATION, + ) + selected = request.GET.get('cert_profile_pk') or request.POST.get('cert_profile_pk') + if selected: + try: + self.profile = get_object_or_404(profiles, pk=int(selected)) + except (ValueError, TypeError) as exception: + raise Http404 from exception + else: + self.profile = get_object_or_404(profiles, unique_name='tls_server') + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self) -> dict[str, Any]: + """Reuse the profile's standard dynamic certificate-content form.""" + return super().get_form_kwargs() | {'profile': self.profile.profile} + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Show only application profiles and the TLS Server default label.""" + return super().get_context_data(**kwargs) | { + 'title': _('Define TLS Certificate Content'), 'submit_label': _('Generate CSR'), + 'cert_profile': self.profile, + 'available_profiles': CertificateProfileModel.objects.filter( + credential_type=CertificateProfileModel.ProfileCredentialType.APPLICATION, + ).order_by('display_name', 'unique_name'), + } + + def form_valid(self, form: CertificateIssuanceForm) -> HttpResponse: + """Validate the profile request and save the exact CSR before showing it.""" + if not self.state.get('truststore_pk'): + return redirect('management:tls-external-csr-truststore', pk=self.credential.pk) + content = json.loads(json.dumps(form.cleaned_data, cls=DjangoJSONEncoder)) + try: + csr = build_external_csr(self.credential, self.profile, content) + except (ValueError, ProfileValidationError, CryptoError) as exception: + form.add_error(None, str(exception)) + return self.form_invalid(form) + self.state.update({ + 'profile_pk': self.profile.pk, 'content': content, + 'csr_pem': csr.public_bytes(Encoding.PEM).decode('ascii'), + }) + self.save_state() + return redirect('management:tls-external-csr', pk=self.credential.pk) + + +class TlsExternalCsrView(TlsExternalCsrWizardView[TlsExternalCsrCertificateForm]): + """Display/download the CSR and accept its issued certificate on the same page.""" + + template_name = 'management/tls/external_csr.html' + form_class = TlsExternalCsrCertificateForm + + def get_csr(self) -> x509.CertificateSigningRequest: + """Read the exact session CSR rather than signing a fresh request on every GET.""" + try: + return x509.load_pem_x509_csr(self.state['csr_pem'].encode('ascii')) + except (KeyError, ValueError) as exception: + raise Http404 from exception + + def get_form_kwargs(self) -> dict[str, Any]: + """Bind the upload form to the same key, request identity, and truststore.""" + return super().get_form_kwargs() | { + 'credential': self.credential, 'csr': self.get_csr(), + 'truststore': get_object_or_404(TruststoreModel, pk=self.state.get('truststore_pk')), + } + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Serve a permission-protected CSR attachment from this same wizard page.""" + if request.GET.get('download') == '1': + return csr_download_response(self.get_csr(), f'tls-{self.credential.pk}') + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Expose the key, profile, and complete requested identity in the overview.""" + csr = self.get_csr() + return super().get_context_data(**kwargs) | { + 'csr_pem': self.state['csr_pem'], 'subject': csr.subject.rfc4514_string(), + 'sans': TlsExternalCsrCertificateForm.san_names(csr), + 'key_type': self.state['key_type'], + 'cert_profile': get_object_or_404(CertificateProfileModel, pk=self.state.get('profile_pk')), + } + + def form_valid(self, form: TlsExternalCsrCertificateForm) -> HttpResponse: + """Finalize the same credential, leave activation unchanged, and clear only its state.""" + try: + finalize_external_certificate(self.credential, form.cleaned_data['certificate'], form.chain) + except ValidationError as exception: + form.add_error(None, exception) + return self.form_invalid(form) + del self.request.session[self.session_key] + return redirect('management:tls') diff --git a/trustpoint/pki/forms/issuing_cas.py b/trustpoint/pki/forms/issuing_cas.py index 8068175db..2df99eebf 100644 --- a/trustpoint/pki/forms/issuing_cas.py +++ b/trustpoint/pki/forms/issuing_cas.py @@ -5,12 +5,11 @@ from __future__ import annotations -from typing import Any, ClassVar, NoReturn, cast +from typing import TYPE_CHECKING, Any, ClassVar, NoReturn, cast from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec, rsa -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from django import forms from django.core.exceptions import ValidationError from django.db import transaction @@ -24,10 +23,8 @@ from crypto.application.capabilities import get_active_backend_capability_report from crypto.application.service import TrustpointCryptoBackend -from crypto.domain.algorithms import EllipticCurveName from crypto.domain.errors import CryptoError from crypto.domain.policies import KeyPolicy, SigningExecutionMode -from crypto.domain.specs import EcKeySpec, KeySpec, MlDsaKeySpec, MlDsaVariant, RsaKeySpec from crypto.models import CryptoManagedKeyModel from onboarding.authorization import PermittedProtocolsAuthorization from onboarding.models import NoOnboardingConfigModel, NoOnboardingPkiProtocol @@ -37,6 +34,13 @@ from pki.models.certificate import CertificateModel from pki.models.credential import CredentialModel from pki.models.truststore import TruststoreModel +from pki.services.external_csr import certificate_matches_credential +from pki.services.key_generation import ( + KEY_TYPE_CHOICES, + generate_pending_credential, + key_spec_for_key_type, + supported_key_type_choices, +) from pki.util.x509 import CertificateVerifier from trustpoint.logger import LoggerMixin from util.field import UniqueNameValidator, get_certificate_name @@ -45,6 +49,9 @@ MAX_PKCS12_UPLOAD_BYTES = 256 * 1024 +if TYPE_CHECKING: + from crypto.domain.specs import KeySpec + def get_ca_type_from_config() -> CaModel.CaTypeChoice: """Return the local managed-CA type used for newly created Trustpoint-managed CAs. @@ -614,17 +621,7 @@ def clean(self) -> None: class IssuingCaAddRequestMixin(LoggerMixin, forms.ModelForm[CaModel]): """Mixin for forms requesting an Issuing CA certificate from remote servers.""" - KEY_TYPE_CHOICES: ClassVar[list[tuple[str, str]]] = [ - ('RSA-2048', 'RSA 2048'), - ('RSA-3072', 'RSA 3072'), - ('RSA-4096', 'RSA 4096'), - ('ECC-SECP256R1', 'ECC SECP256R1'), - ('ECC-SECP384R1', 'ECC SECP384R1'), - ('ECC-SECP521R1', 'ECC SECP521R1'), - ('MLDSA-44', 'ML-DSA-44'), - ('MLDSA-65', 'ML-DSA-65'), - ('MLDSA-87', 'ML-DSA-87'), - ] + KEY_TYPE_CHOICES: ClassVar[list[tuple[str, str]]] = KEY_TYPE_CHOICES class Meta: """Meta class for IssuingCaAddRequestMixin.""" @@ -657,23 +654,12 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: @classmethod def _key_spec_for_key_type(cls, key_type: str) -> KeySpec: """Map the form key type to a backend key spec.""" - if key_type.startswith('RSA-'): - return RsaKeySpec(key_size=int(key_type.split('-')[1])) - if key_type.startswith('MLDSA-'): - variant_str = 'mldsa' + key_type.split('-')[1] - return MlDsaKeySpec(variant=MlDsaVariant(variant_str)) - curve_name = key_type.split('-')[1] - return EcKeySpec(curve=EllipticCurveName(curve_name.lower())) + return key_spec_for_key_type(key_type) @classmethod def _supported_key_type_choices(cls) -> list[tuple[str, str]]: """Return key type choices supported by the active backend.""" - report = get_active_backend_capability_report() - return [ - (value, label) - for value, label in cls.KEY_TYPE_CHOICES - if report.supports_key_spec(cls._key_spec_for_key_type(value)) - ] + return supported_key_type_choices(cls.KEY_TYPE_CHOICES, report=get_active_backend_capability_report()) def clean(self) -> dict[str, Any]: """Validate the form data.""" @@ -706,20 +692,15 @@ def clean(self) -> dict[str, Any]: def _create_credential(self) -> CredentialModel: """Create and return a temporary credential for the CA.""" - key_type = self.cleaned_data['key_type'] - key_spec = self._key_spec_for_key_type(key_type) - - key_ref = TrustpointCryptoBackend().generate_managed_key( + return generate_pending_credential( alias=self.cleaned_data['unique_name'], - key_spec=key_spec, + key_type=self.cleaned_data['key_type'], + credential_type=CredentialModel.CredentialTypeChoice.ISSUING_CA, + backend=TrustpointCryptoBackend(), policy=KeyPolicy.managed_signing_key( signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH, ), ) - return CredentialModel.save_managed_private_key_credential( - credential_type=CredentialModel.CredentialTypeChoice.ISSUING_CA, - managed_key=CryptoManagedKeyModel.objects.get(pk=key_ref.id), - ) def save(self, *, commit: bool = True) -> CaModel: # type: ignore[override] """Save the form and create the CA model with configuration. @@ -863,10 +844,7 @@ def clean_certificate(self) -> CertificateSerializer: if self.instance.credential is None: raise forms.ValidationError(_('The Issuing CA credential is missing.')) - expected_public_key = self.instance.credential.get_private_key().public_key() - expected_public_key_der = expected_public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) - actual_public_key_der = certificate.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) - if actual_public_key_der != expected_public_key_der: + if not certificate_matches_credential(certificate, self.instance.credential): raise forms.ValidationError(_('The uploaded certificate does not match the generated CSR.')) return certificate_serializer diff --git a/trustpoint/pki/models/credential.py b/trustpoint/pki/models/credential.py index 9d4937aed..819ec2516 100644 --- a/trustpoint/pki/models/credential.py +++ b/trustpoint/pki/models/credential.py @@ -21,6 +21,7 @@ ) from crypto.application.private_keys import ManagedECPrivateKey, ManagedRSAPrivateKey, managed_private_key_for_ref +from crypto.application.service import TrustpointCryptoBackend from crypto.models import CryptoManagedKeyModel from pki.models import CertificateModel from pki.models.issued_credential import RemoteIssuedCredentialModel @@ -480,13 +481,18 @@ def get_private_key_serializer(self) -> PrivateKeySerializer: PrivateKeySerializer: The credential private key serializer. Raises: - RuntimeError: If no private key information is available or if the key is backend-managed. + RuntimeError: If no private key information is available or managed TLS export is denied. """ if self.private_key: return PrivateKeySerializer.from_pem(self.private_key.encode()) if self.managed_private_key: try: + if self.credential_type == self.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER: + private_key_pem = TrustpointCryptoBackend().export_private_key_pkcs8( + self.managed_private_key.to_managed_key_ref() + ) + return PrivateKeySerializer.from_pem(private_key_pem) managed_key = managed_private_key_for_ref(self.managed_private_key.to_managed_key_ref()) return self._private_key_serializer_from_key(managed_key) except Exception as e: diff --git a/trustpoint/pki/services/external_csr.py b/trustpoint/pki/services/external_csr.py new file mode 100644 index 000000000..fd72a5d57 --- /dev/null +++ b/trustpoint/pki/services/external_csr.py @@ -0,0 +1,119 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Shared operations for externally issued certificate requests.""" + +from typing import Any + +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from django.core.exceptions import ValidationError +from django.db import transaction +from django.http import HttpResponse + +from pki.models import CredentialModel +from pki.models.cert_profile import CertificateProfileModel +from pki.models.certificate import CertificateModel +from pki.models.credential import CertificateChainOrderModel, PrimaryCredentialCertificate +from request.operation_processor.csr_build import ProfileAwareCsrBuilder +from request.request_context import CmpCertificateRequestContext + + +def certificate_matches_credential(certificate: x509.Certificate, credential: CredentialModel) -> bool: + """Compare the certificate's public key with the existing managed key.""" + expected = credential.get_private_key().public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + actual = certificate.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return actual == expected + + +def parse_single_certificate(data: bytes) -> x509.Certificate: + """Accept exactly one PEM or DER certificate, never a bundle or container.""" + if not data or len(data) > 64 * 1024: + msg = 'Upload one certificate, at most 64 KiB.' + raise ValidationError(msg) + try: + if data.lstrip().startswith(b'-----BEGIN'): + certificate = x509.load_pem_x509_certificate(data) + if data.strip() != certificate.public_bytes(Encoding.PEM).strip(): + msg = 'Upload only the issued leaf certificate, not a bundle.' + raise ValidationError(msg) + else: + certificate = x509.load_der_x509_certificate(data) + if data != certificate.public_bytes(Encoding.DER): + msg = 'Upload only the issued leaf certificate.' + raise ValidationError(msg) + except ValueError as exception: + msg = 'Unable to parse the issued certificate.' + raise ValidationError(msg) from exception + return certificate + + +def request_data_from_content(content: dict[str, Any]) -> dict[str, Any]: + """Convert the flat certificate-content form values into profile request data.""" + subject_fields = ( + 'common_name', 'organization_name', 'organizational_unit_name', 'country_name', + 'state_or_province_name', 'locality_name', 'email_address', + ) + san_fields = ('dns_names', 'ip_addresses', 'rfc822_names', 'uris') + return { + 'subj': {field: content[field] for field in subject_fields if content.get(field)}, + 'ext': {'subject_alternative_name': { + field: ( + [str(value).strip() for value in content[field] if str(value).strip()] + if isinstance(content[field], list) + else [value.strip() for value in str(content[field]).split(',') if value.strip()] + ) for field in san_fields if content.get(field) + }}, + 'validity': { + field: int(content[field]) for field in ('days', 'hours', 'minutes', 'seconds') + if content.get(field) is not None + }, + } + + +def build_external_csr( + credential: CredentialModel, profile: CertificateProfileModel, content: dict[str, Any], *, allow_ca: bool = False, +) -> x509.CertificateSigningRequest: + """Build a profile-aware CSR using the existing credential's managed key.""" + context = CmpCertificateRequestContext( + operation='certification', protocol='cmp', domain=None, + cert_profile_str=profile.unique_name, certificate_profile_model=profile, + allow_ca_certificate_request=allow_ca, + ) + context.request_data = request_data_from_content(content) + context.owner_credential = credential + builder = ProfileAwareCsrBuilder() + builder.process_operation(context) + return builder.get_csr() + + +def csr_download_response(csr: x509.CertificateSigningRequest, filename: str) -> HttpResponse: + """Return the CSR as a PEM attachment.""" + response = HttpResponse(csr.public_bytes(Encoding.PEM), content_type='application/pkcs10') + response['Content-Disposition'] = f'attachment; filename="{filename}.csr"' + return response + + +@transaction.atomic +def finalize_external_certificate( + credential: CredentialModel, certificate: x509.Certificate, chain: list[x509.Certificate] | None = None, +) -> None: + """Attach a leaf and root-first issuer chain without replacing the pending key.""" + pending = CredentialModel.objects.select_for_update().get(pk=credential.pk) + if pending.certificate_id is not None: + msg = 'This credential already has a certificate.' + raise ValidationError(msg) + if not certificate_matches_credential(certificate, pending): + msg = 'The issued certificate does not match the pending key.' + raise ValidationError(msg) + leaf = CertificateModel.save_certificate(certificate) + pending.certificate = leaf + pending.save(update_fields=['certificate']) + PrimaryCredentialCertificate.objects.update_or_create( + credential=pending, defaults={'certificate': leaf, 'is_primary': True}, + ) + for order, issuer in enumerate(reversed((chain or [certificate])[1:])): + CertificateChainOrderModel.objects.create( + credential=pending, certificate=CertificateModel.save_certificate(issuer), + primary_certificate=leaf, order=order, + ) diff --git a/trustpoint/pki/services/key_generation.py b/trustpoint/pki/services/key_generation.py new file mode 100644 index 000000000..9d99c774a --- /dev/null +++ b/trustpoint/pki/services/key_generation.py @@ -0,0 +1,77 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Shared backend-aware key selection and pending credential creation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from crypto.application.capabilities import get_active_backend_capability_report +from crypto.application.service import TrustpointCryptoBackend +from crypto.domain.algorithms import EllipticCurveName +from crypto.domain.policies import KeyPolicy, SigningExecutionMode +from crypto.domain.specs import EcKeySpec, KeySpec, MlDsaKeySpec, MlDsaVariant, RsaKeySpec +from crypto.models import CryptoManagedKeyModel +from pki.models.credential import CredentialModel + +if TYPE_CHECKING: + from collections.abc import Sequence + + from crypto.application.capabilities import BackendCapabilityReport + +KEY_TYPE_CHOICES = [ + ('RSA-2048', 'RSA 2048'), + ('RSA-3072', 'RSA 3072'), + ('RSA-4096', 'RSA 4096'), + ('ECC-SECP256R1', 'ECC SECP256R1'), + ('ECC-SECP384R1', 'ECC SECP384R1'), + ('ECC-SECP521R1', 'ECC SECP521R1'), + ('MLDSA-44', 'ML-DSA-44'), + ('MLDSA-65', 'ML-DSA-65'), + ('MLDSA-87', 'ML-DSA-87'), +] + + +def key_spec_for_key_type(key_type: str) -> KeySpec: + """Map a UI key type to the backend key specification.""" + if key_type.startswith('RSA-'): + return RsaKeySpec(key_size=int(key_type.split('-')[1])) + if key_type.startswith('MLDSA-'): + return MlDsaKeySpec(variant=MlDsaVariant('mldsa' + key_type.split('-')[1])) + return EcKeySpec(curve=EllipticCurveName(key_type.split('-')[1].lower())) + + +def supported_key_type_choices( + choices: Sequence[tuple[str, str]] = KEY_TYPE_CHOICES, + *, + report: BackendCapabilityReport | None = None, +) -> list[tuple[str, str]]: + """Filter UI key types using the active backend's capabilities.""" + capability_report = report if report is not None else get_active_backend_capability_report() + return [ + (value, label) for value, label in choices + if capability_report.supports_key_spec(key_spec_for_key_type(value)) + ] + + +def generate_pending_credential( + *, + alias: str, + key_type: str, + credential_type: CredentialModel.CredentialTypeChoice, + policy: KeyPolicy | None = None, + backend: TrustpointCryptoBackend | None = None, +) -> CredentialModel: + """Generate one backend key and persist a certificate-less credential.""" + key_ref = (backend or TrustpointCryptoBackend()).generate_managed_key( + alias=alias, + key_spec=key_spec_for_key_type(key_type), + policy=policy or KeyPolicy.managed_signing_key( + signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH, + ), + ) + return CredentialModel.save_managed_private_key_credential( + credential_type=credential_type, + managed_key=CryptoManagedKeyModel.objects.get(pk=key_ref.id), + ) diff --git a/trustpoint/pki/tests/test_external_csr.py b/trustpoint/pki/tests/test_external_csr.py new file mode 100644 index 000000000..02c6d64ab --- /dev/null +++ b/trustpoint/pki/tests/test_external_csr.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Tests for shared external CSR operations with actual managed keys.""" + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from django.core.management import call_command + +from pki.models import CredentialModel +from pki.models.cert_profile import CertificateProfileModel +from pki.services.key_generation import generate_pending_credential +from request.operation_processor.csr_build import ProfileAwareCsrBuilder +from request.request_context import CmpCertificateRequestContext + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize('key_type', ['ECC-SECP256R1', 'RSA-2048']) +def test_actual_managed_key_csr(key_type): + call_command('create_default_cert_profiles') + credential = generate_pending_credential( + alias=f'external-csr/{key_type}', key_type=key_type, + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + ) + profile = CertificateProfileModel.objects.get(unique_name='tls_server') + context = CmpCertificateRequestContext( + operation='certification', protocol='cmp', domain=None, + cert_profile_str=profile.unique_name, certificate_profile_model=profile, + ) + context.owner_credential = credential + context.request_data = { + 'subj': {'common_name': 'server.example', 'organization_name': 'Example', 'country_name': 'DE'}, + 'ext': {'subject_alternative_name': {'dns_names': ['server.example']}}, + 'validity': {'days': 10}, + } + builder = ProfileAwareCsrBuilder() + builder.process_operation(context) + csr = builder.get_csr() + assert csr.is_signature_valid + assert csr.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) == ( + credential.get_private_key().public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + ) + assert csr.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value == 'server.example' \ No newline at end of file diff --git a/trustpoint/pki/util/cert_req_converter.py b/trustpoint/pki/util/cert_req_converter.py index 5c4739eac..685687a60 100644 --- a/trustpoint/pki/util/cert_req_converter.py +++ b/trustpoint/pki/util/cert_req_converter.py @@ -6,7 +6,7 @@ import datetime import ipaddress import logging -from typing import Any +from typing import Any, cast from cryptography import x509 from trustpoint_core.oid import NameOid @@ -166,10 +166,10 @@ def _ku_from_json(ext_value: dict[str, Any]) -> x509.KeyUsage: ) @staticmethod - def _ext_from_json( # noqa: C901 + def add_extensions( # noqa: C901 json: dict[str, Any], - builder: x509.CertificateBuilder - ) -> x509.CertificateBuilder: + builder: x509.CertificateBuilder | x509.CertificateSigningRequestBuilder, + ) -> x509.CertificateBuilder | x509.CertificateSigningRequestBuilder: """Processes JSON data to add X.509 certificate extensions to a CertificateBuilder. Args: @@ -219,6 +219,12 @@ def _ext_from_json( # noqa: C901 logger.debug('JSON Cert Request Adapter: Skipping unsupported extension: %s', ext_name) return builder + @staticmethod + def _ext_from_json(json: dict[str, Any], builder: x509.CertificateBuilder) -> x509.CertificateBuilder: + """Preserve the certificate converter's existing extension API.""" + result = JSONCertRequestConverter.add_extensions(json, builder) + return cast('x509.CertificateBuilder', result) + @staticmethod def validity_period_from_json(validity: dict[str, Any]) -> datetime.timedelta: """Parses validity period from JSON.""" diff --git a/trustpoint/pki/util/x509.py b/trustpoint/pki/util/x509.py index 39d8698f4..b3d2d4b92 100644 --- a/trustpoint/pki/util/x509.py +++ b/trustpoint/pki/util/x509.py @@ -10,6 +10,7 @@ import logging import urllib.parse from datetime import UTC +from ipaddress import ip_address from typing import TYPE_CHECKING, TypeGuard, get_args from cryptography import x509 @@ -710,11 +711,16 @@ def verify_server_cert( if verification_time is None: verification_time = datetime.datetime.now(UTC) + try: + identity: x509.DNSName | x509.IPAddress = x509.IPAddress(ip_address(subject)) + except ValueError: + identity = x509.DNSName(subject) + verifier = ( PolicyBuilder() .store(trust_store) .time(verification_time) - .build_server_verifier(x509.DNSName(subject)) + .build_server_verifier(identity) ) if untrusted_intermediates is None: diff --git a/trustpoint/request/operation_processor/csr_build.py b/trustpoint/request/operation_processor/csr_build.py index 362d3775b..92cb2649e 100644 --- a/trustpoint/request/operation_processor/csr_build.py +++ b/trustpoint/request/operation_processor/csr_build.py @@ -19,6 +19,7 @@ ) from pki.util.cert_profile import JSONProfileVerifier +from pki.util.cert_req_converter import JSONCertRequestConverter from request.request_context import BaseCertificateRequestContext, BaseRequestContext from request.template_vars import resolve_template_variables from trustpoint.logger import LoggerMixin @@ -59,14 +60,16 @@ def process_operation(self, context: BaseRequestContext) -> None: validated_request_data = context.validated_request_data subject = self._build_subject(validated_request_data) - extensions = self._build_extensions(validated_request_data) private_key = self._get_private_key(context) csr_builder = CertificateSigningRequestBuilder().subject_name(subject) - for ext_value, critical in extensions: - csr_builder = csr_builder.add_extension(ext_value, critical=critical) + normalized_request = JSONProfileVerifier.validate_request(validated_request_data) + extended_builder = JSONCertRequestConverter.add_extensions(normalized_request, csr_builder) + if not isinstance(extended_builder, CertificateSigningRequestBuilder): + msg = 'Expected a CSR builder.' + raise TypeError(msg) - self._csr = csr_builder.sign(private_key, hashes.SHA256()) + self._csr = extended_builder.sign(private_key, hashes.SHA256()) self.logger.info('Built CSR with subject: %s', subject.rfc4514_string()) diff --git a/trustpoint/static/js/management/tls_external_csr.js b/trustpoint/static/js/management/tls_external_csr.js new file mode 100644 index 000000000..de69f3d9d --- /dev/null +++ b/trustpoint/static/js/management/tls_external_csr.js @@ -0,0 +1,18 @@ +document.addEventListener('DOMContentLoaded', () => { + const profileSelect = document.getElementById('id_cert_profile_select'); + profileSelect?.addEventListener('change', () => { + const url = new URL(window.location.href); + url.searchParams.set('cert_profile_pk', profileSelect.value); + window.location.href = url.toString(); + }); + const button = document.getElementById('copy-csr-button'); + const textarea = document.getElementById('id_csr_pem'); + button?.addEventListener('click', async () => { + textarea.select(); + try { + await navigator.clipboard.writeText(textarea.value); + } catch { + document.execCommand('copy'); + } + }); +}); \ No newline at end of file diff --git a/trustpoint/templates/management/tls/external_csr.html b/trustpoint/templates/management/tls/external_csr.html new file mode 100644 index 000000000..22c37aeb0 --- /dev/null +++ b/trustpoint/templates/management/tls/external_csr.html @@ -0,0 +1,85 @@ +{% extends 'trustpoint/base.html' %} +{% load i18n crispy_forms_filters static %} + +{% block content %} +
+
+
+
+

{% trans 'External PKI' %}: {% trans 'TLS Certificate' %}

+

{% trans 'The TLS certificate is waiting for issuance by the external PKI.' %}

+
+ {% trans 'CSR' %} +
+
+
+
+ +
+
+

{% trans 'Certificate Content' %}

+

{% trans 'Review the certificate content included in the generated request.' %}

+
+
+ +
+
+
+
+

{% trans 'Certificate Signing Request' %}

+

{% trans 'Copy or download the CSR and submit it to your external PKI.' %}

+
+
+
+ + +
+ + {% trans 'Download CSR' %} +
+
+
+
+

{% trans 'Upload Issued Certificate' %}

+

{% trans 'Upload the TLS certificate returned by your external PKI.' %}

+
+
+
+ {% csrf_token %} + {{ form|crispy }} +
+
+
+
+ +
+ +{% endblock content %} \ No newline at end of file diff --git a/trustpoint/templates/management/tls/external_csr_fields.html b/trustpoint/templates/management/tls/external_csr_fields.html new file mode 100644 index 000000000..2b5939f75 --- /dev/null +++ b/trustpoint/templates/management/tls/external_csr_fields.html @@ -0,0 +1,26 @@ +
+ {% for field in form %} + {% if not field_names or field.name in field_names %} + {% if field.is_hidden %} + {{ field }} + {% else %} +
+
+ +
+
+ {{ field }} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endif %} +
+
+ {% endif %} + {% endif %} + {% endfor %} +
\ No newline at end of file diff --git a/trustpoint/templates/management/tls/external_csr_form.html b/trustpoint/templates/management/tls/external_csr_form.html new file mode 100644 index 000000000..53f6568b0 --- /dev/null +++ b/trustpoint/templates/management/tls/external_csr_form.html @@ -0,0 +1,147 @@ +{% extends 'trustpoint/base.html' %} +{% load i18n crispy_forms_filters static %} + +{% block content %} +
+
+

{{ title }}

+

+ {% if cert_profile %} + {% blocktranslate with profile_name=cert_profile.display_name|default:cert_profile.unique_name %} + Define the certificate content for profile "{{ profile_name }}". + {% endblocktranslate %} + {% elif import_form %} + {% trans 'Select the external CA chain for the TLS certificate.' %} + {% else %} + {% trans 'Select the key type for the TLS certificate request.' %} + {% endif %} +

+
+
+
+
+ {% csrf_token %} + {% if cert_profile %}{% endif %} + {% if form.non_field_errors %} + + {% endif %} + {% if cert_profile %} +
+
+
+

{% trans 'Certificate Profile' %}

+

{% trans 'Select the certificate profile that defines the available fields and certificate requirements.' %}

+
+ {% if cert_profile.unique_name == 'tls_server' %}{% trans 'TLS Server' %}{% else %}{{ cert_profile.display_name|default:cert_profile.unique_name }}{% endif %} +
+
+
+
+
+
+ +
+
+
+
+
+
+

{% trans 'Subject' %}

+

{% trans 'Define the distinguished name information included in the certificate subject.' %}

+
+
+ {% include 'management/tls/external_csr_fields.html' with field_names='common_name,organization_name,organizational_unit_name,country_name,state_or_province_name,locality_name,email_address,serial_number' %} +
+
+
+

{% trans 'Subject Alternative Names' %}

+

{% trans 'Define additional identities such as DNS names, IP addresses, email addresses or URIs.' %}

+
+
+ {% include 'management/tls/external_csr_fields.html' with field_names='dns_names,ip_addresses,rfc822_names,uris' %} +
+
+
+

{% trans 'Validity Period' %}

+

{% trans 'Define the validity period for the certificate.' %}

+
+
+ {% include 'management/tls/external_csr_fields.html' with field_names='not_before,not_after,days,hours,minutes,seconds' %} +
+ {% else %} +
+
+

+ {% if import_form %}{% trans 'Trust Store Association' %}{% else %}{% trans 'Key Type' %}{% endif %} +

+ {% if import_form %}

{% trans 'The selected trust store defines the trusted certificates used for this TLS certificate.' %}

{% endif %} +
+
+ {% if import_form %} + + {{ form|crispy }} + {% else %} + {% include 'management/tls/external_csr_fields.html' %} + {% endif %} +
+ {% endif %} +
+ {% if import_form %} +
+

{% trans 'The required trust store is not available yet?' %}

+ +
+ {% endif %} +
+
+ +
+{% if import_form %} + +{% if import_form.errors %}{% endif %} +{% endif %} + +{% endblock content %} \ No newline at end of file diff --git a/trustpoint/templates/management/tls/external_csr_key.html b/trustpoint/templates/management/tls/external_csr_key.html new file mode 100644 index 000000000..0c1cda08f --- /dev/null +++ b/trustpoint/templates/management/tls/external_csr_key.html @@ -0,0 +1,47 @@ +{% extends 'trustpoint/base.html' %} +{% load i18n crispy_forms_filters %} + +{% block content %} +
+
+

{% trans 'Add New TLS Certificate' %}

+

+ {% trans 'Generate a new keypair in Trustpoint and request the TLS certificate from an external PKI.' %} +

+
+ +
+
+
+
+

{% trans 'External PKI' %}

+

+ {% trans 'Create the TLS keypair and prepare Trustpoint for a manual certificate request.' %} +

+
+
+ +
+ + + +
+ {% csrf_token %} + {{ form|crispy }} +
+
+
+ + +
+{% endblock content %} \ No newline at end of file diff --git a/trustpoint/templates/management/tls/method_select.html b/trustpoint/templates/management/tls/method_select.html index 69efc83f9..5a119bd03 100644 --- a/trustpoint/templates/management/tls/method_select.html +++ b/trustpoint/templates/management/tls/method_select.html @@ -46,10 +46,6 @@

{% trans 'PKCS#12 File' %}

- - - {% trans 'TLS' %} -

@@ -82,10 +78,6 @@

{% trans 'Separate Files' %}

- - - {% trans 'TLS' %} -

@@ -111,6 +103,36 @@

{# ============================================================ #} {# Generate TLS Certificate #} {# ============================================================ #} +
+
+

{% trans 'External PKI' %}

+

+ {% trans 'Generate a keypair in Trustpoint and request a TLS certificate from an external PKI.' %} +

+
+ +
+
@@ -139,8 +161,8 @@

{% trans 'Generate in Trustpoint' %}

- - {% trans 'TLS' %} + + {% trans 'self-signed' %}
diff --git a/trustpoint/templates/pki/issuing_cas/add/method_select.html b/trustpoint/templates/pki/issuing_cas/add/method_select.html index 18a2550e4..0d51792e1 100644 --- a/trustpoint/templates/pki/issuing_cas/add/method_select.html +++ b/trustpoint/templates/pki/issuing_cas/add/method_select.html @@ -184,7 +184,7 @@

- {% trans 'External PKI' %} + {% trans 'Generate CSR' %}