diff --git a/.env.example b/.env.example index cb40876fa..6c81fd4b6 100644 --- a/.env.example +++ b/.env.example @@ -128,6 +128,7 @@ TP_SECURITY_MODE=LAB # TP_SECURITY_RSA_MINIMUM_KEY_SIZE=2048 # TP_SECURITY_MAX_CERT_VALIDITY_DAYS=365 # TP_SECURITY_MAX_CRL_VALIDITY_DAYS=90 +# Default lifetime for CMP, EST, and REST onboarding credentials, in seconds (null for no expiry). # TP_SECURITY_ALLOW_CA_ISSUANCE=false # TP_SECURITY_ALLOW_AUTO_GEN_PKI=false # TP_SECURITY_ALLOW_SELF_SIGNED_CA=false diff --git a/trustpoint/devices/serializers.py b/trustpoint/devices/serializers.py index eff78061f..81f47e773 100644 --- a/trustpoint/devices/serializers.py +++ b/trustpoint/devices/serializers.py @@ -8,8 +8,10 @@ """ import secrets +from datetime import timedelta from typing import Any, ClassVar +from django.utils import timezone from rest_framework import serializers from onboarding.models import OnboardingConfigModel, OnboardingPkiProtocol, OnboardingProtocol @@ -53,6 +55,18 @@ class OnboardingConfigSerializer(serializers.ModelSerializer[OnboardingConfigMod ) ) + credential_ttl_seconds = serializers.IntegerField( + required=False, + write_only=True, + min_value=1, + help_text=( + 'Optional validity window (in seconds) for CMP, EST, and REST credentials. ' + 'When provided, credential_expires_at is set to now + ttl. ' + 'If omitted, new credentials use the TTL from Security Configuration ' + 'unless an expiry is explicitly given.' + ), + ) + class Meta: """Metadata for OnboardingConfigSerializer.""" @@ -64,6 +78,8 @@ class Meta: 'pki_protocols', 'est_password', 'cmp_shared_secret', + 'credential_expires_at', + 'credential_ttl_seconds', 'opc_user', 'opc_password', 'idevid_trust_store', @@ -87,6 +103,25 @@ class Meta: }, } + def _apply_credential_expiry( + self, + data: dict[str, Any], + ttl_seconds: int | None, + ) -> None: + """Resolve the credential expiry into data['credential_expires_at']. + + Precedence: + 1. An explicit credential_expires_at already in data is left untouched. + 2. An explicit ttl_seconds sets expiry to now + ttl. + 3. The model applies the security configuration default when saving a new credential. + """ + if data.get('credential_expires_at') is not None: + return + + if ttl_seconds is not None: + data['credential_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) + return + def _generate_secure_secret(self, length: int = 32) -> str: """Generate a cryptographically secure random secret. @@ -138,6 +173,7 @@ def create(self, validated_data: dict[str, Any]) -> OnboardingConfigModel: - EST_USERNAME_PASSWORD or REST_USERNAME_PASSWORD: generates est_password """ pki_protocol_values = validated_data.pop('pki_protocols', []) + ttl_seconds = validated_data.pop('credential_ttl_seconds', None) onboarding_protocol = validated_data.get('onboarding_protocol') if onboarding_protocol == OnboardingProtocol.CMP_SHARED_SECRET and not validated_data.get('cmp_shared_secret'): @@ -147,6 +183,8 @@ def create(self, validated_data: dict[str, Any]) -> OnboardingConfigModel: and not validated_data.get('est_password')): validated_data['est_password'] = self._generate_secure_secret() + self._apply_credential_expiry(validated_data, ttl_seconds) + instance = OnboardingConfigModel(**validated_data) if pki_protocol_values: @@ -159,6 +197,12 @@ def create(self, validated_data: dict[str, Any]) -> OnboardingConfigModel: def update(self, instance: OnboardingConfigModel, validated_data: dict[str, Any]) -> OnboardingConfigModel: """Update OnboardingConfigModel instance with proper PKI protocol handling.""" pki_protocol_values = validated_data.pop('pki_protocols', None) + ttl_seconds = validated_data.pop('credential_ttl_seconds', None) + + # Resolve an explicit TTL into an expiry timestamp. A TTL always takes + # precedence over any expiry already present in the update payload. + if ttl_seconds is not None: + validated_data['credential_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/trustpoint/devices/tests/test_api/test_credential_expiry.py b/trustpoint/devices/tests/test_api/test_credential_expiry.py new file mode 100644 index 000000000..041c15f52 --- /dev/null +++ b/trustpoint/devices/tests/test_api/test_credential_expiry.py @@ -0,0 +1,120 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Tests for protocol-independent credential expiry API inputs.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +from devices.serializers import OnboardingConfigSerializer +from management.models import SecurityConfig +from onboarding.models import OnboardingConfigModel, OnboardingProtocol + + +@pytest.mark.django_db +@pytest.mark.parametrize('protocol', [ + OnboardingProtocol.CMP_SHARED_SECRET, + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, +]) +def test_credential_ttl_create_and_update(protocol: OnboardingProtocol) -> None: + """The same TTL input applies to CMP, EST, and REST and survives database storage.""" + now = datetime(2026, 10, 2, tzinfo=timezone.utc) + serializer = OnboardingConfigSerializer(data={ + 'onboarding_protocol': protocol, + 'credential_ttl_seconds': 120, + }) + assert serializer.is_valid(), serializer.errors + with patch('devices.serializers.timezone.now', return_value=now): + config = serializer.save() + config.refresh_from_db() + assert config.credential_expires_at == now + timedelta(seconds=120) + assert 'credential_ttl_seconds' not in serializer.data + + update = OnboardingConfigSerializer(config, data={'credential_ttl_seconds': 240}, partial=True) + assert update.is_valid(), update.errors + with patch('devices.serializers.timezone.now', return_value=now): + update.save() + config.refresh_from_db() + assert config.credential_expires_at == now + timedelta(seconds=240) + + +@pytest.mark.django_db +@pytest.mark.parametrize('protocol', [ + OnboardingProtocol.CMP_SHARED_SECRET, + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, +]) +def test_explicit_credential_expiry(protocol: OnboardingProtocol) -> None: + """All protocols accept an explicit timestamp and allow clearing the expiry.""" + expiry = datetime(2026, 10, 2, tzinfo=timezone.utc) + serializer = OnboardingConfigSerializer(data={ + 'onboarding_protocol': protocol, + 'credential_expires_at': expiry.isoformat(), + }) + assert serializer.is_valid(), serializer.errors + config = serializer.save() + config.refresh_from_db() + assert config.credential_expires_at == expiry + assert 'credential_expires_at' in serializer.data + + update = OnboardingConfigSerializer(config, data={'credential_expires_at': None}, partial=True) + assert update.is_valid(), update.errors + update.save() + config.refresh_from_db() + assert config.credential_expires_at is None + + +@pytest.mark.django_db +@pytest.mark.parametrize('mode', list(SecurityConfig.SecurityModeChoices)) +@pytest.mark.parametrize('protocol', [ + OnboardingProtocol.CMP_SHARED_SECRET, + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, +]) +def test_security_mode_credential_ttl(mode: str, protocol: OnboardingProtocol) -> None: + """Both API and direct model creation use the configured mode's credential TTL.""" + security = SecurityConfig.objects.create(pk=1, security_mode=mode) + security.apply_security_settings() + now = datetime(2026, 10, 2, tzinfo=timezone.utc) + expected = now + timedelta(seconds=600) if mode in ( + SecurityConfig.SecurityModeChoices.HARDENED, + SecurityConfig.SecurityModeChoices.CRITICAL, + ) else None + serializer = OnboardingConfigSerializer(data={'onboarding_protocol': protocol}) + assert serializer.is_valid(), serializer.errors + credential_field = 'cmp_shared_secret' if protocol == OnboardingProtocol.CMP_SHARED_SECRET else 'est_password' + with patch('onboarding.models.timezone.now', return_value=now): + api_config = serializer.save() + model_config = OnboardingConfigModel.objects.create( + onboarding_protocol=protocol, **{credential_field: 'test-secret'}, + ) + for config in (api_config, model_config): + config.refresh_from_db() + assert config.credential_expires_at == expected + with patch('onboarding.models.timezone.now', return_value=now + timedelta(seconds=900)): + config.save() + config.refresh_from_db() + assert config.credential_expires_at == expected + + +@pytest.mark.django_db +@pytest.mark.parametrize('ttl_seconds', [None, 300]) +@pytest.mark.parametrize('protocol', [ + OnboardingProtocol.CMP_SHARED_SECRET, + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, +]) +def test_custom_security_credential_ttl(ttl_seconds: int | None, protocol: OnboardingProtocol) -> None: + """A custom security TTL applies to all three shared-credential protocols.""" + SecurityConfig.objects.create(pk=1, credential_ttl_seconds=ttl_seconds) + now = datetime(2026, 10, 2, tzinfo=timezone.utc) + serializer = OnboardingConfigSerializer(data={'onboarding_protocol': protocol}) + assert serializer.is_valid(), serializer.errors + with patch('onboarding.models.timezone.now', return_value=now): + config = serializer.save() + assert config.credential_expires_at == ( + None if ttl_seconds is None else now + timedelta(seconds=ttl_seconds) + ) \ No newline at end of file diff --git a/trustpoint/features/steps/common_steps.py b/trustpoint/features/steps/common_steps.py index 96f351785..28c51454b 100644 --- a/trustpoint/features/steps/common_steps.py +++ b/trustpoint/features/steps/common_steps.py @@ -234,7 +234,7 @@ def step_security_option_configure(context: runner.Context, opt_name: str, opt_v assert SecurityConfig.objects.filter(id=1).exists(), 'SecurityConfig does not exist' form = response.context['security_form'] - data = form.initial.copy() + data = {name: '' if value is None else value for name, value in form.initial.items()} data['max_cert_validity_days'] = 1825 data['max_crl_validity_days'] = 365 diff --git a/trustpoint/help_pages/devices_help_views.py b/trustpoint/help_pages/devices_help_views.py index d904695ae..d4aa96100 100644 --- a/trustpoint/help_pages/devices_help_views.py +++ b/trustpoint/help_pages/devices_help_views.py @@ -7,13 +7,17 @@ import ipaddress import json +import secrets +from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, override from cryptography import x509 from django.conf import settings from django.contrib import messages +from django.core.exceptions import PermissionDenied from django.core.management import call_command +from django.db import transaction from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect from django.urls import reverse from django.utils import timezone @@ -47,6 +51,8 @@ ) from help_pages.forms import IpAddressForm from help_pages.help_section import HelpPage, HelpRow, HelpSection, ValueRenderType +from management.models import SecurityConfig +from onboarding.models import OnboardingConfigModel, OnboardingProtocol from pki.models import IssuedCredentialModel from pki.models.certificate import RevokedCertificateModel from pki.models.truststore import ActiveTrustpointTlsServerCredentialModel @@ -65,6 +71,8 @@ if TYPE_CHECKING: from typing import Any + from django.http import HttpRequest + from pki.models import CaModel, CredentialModel @@ -75,7 +83,7 @@ class BaseHelpView(UserPermissionRequiredMixin, PageContextMixin, DetailView[Dev """Base help view that constructs the context.""" template_name = 'help/help_page.html' - http_method_names = ('get',) + http_method_names = ('get', 'post') model = DeviceModel context_object_name = 'device' permission_required = AppPermissions.VIEW_HELP_PAGES @@ -84,6 +92,48 @@ class BaseHelpView(UserPermissionRequiredMixin, PageContextMixin, DetailView[Dev page_name: str strategy: HelpPageStrategy + def uses_onboarding_credential(self) -> bool: + """Return whether this help page uses an onboarding password or shared secret.""" + return isinstance(self.strategy, ( + OnboardingDomainCredentialCmpSharedSecretStrategy, + OnboardingDomainCredentialEstUsernamePasswordStrategy, + OnboardingDomainCredentialRestUsernamePasswordStrategy, + AgentSetupProfileStrategy, + )) + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseRedirect: + """Rotate an expired onboarding credential and restart its configured lifetime.""" + del args, kwargs + if not request.user.has_perm(AppPermissions.MANAGE_DEVICES): + raise PermissionDenied + self.object = self.get_object() + if not self.uses_onboarding_credential() or not self.object.onboarding_config_id: + raise Http404(_('No renewable onboarding credential is configured for this device.')) + with transaction.atomic(): + config = OnboardingConfigModel.objects.select_for_update().get(pk=self.object.onboarding_config_id) + if config.onboarding_protocol == OnboardingProtocol.CMP_SHARED_SECRET: + credential_field = 'cmp_shared_secret' + elif config.onboarding_protocol in ( + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, + OnboardingProtocol.AGENT, + ): + credential_field = 'est_password' + else: + raise Http404(_('No renewable onboarding credential is configured for this device.')) + if config.is_credential_expired() and getattr(config, credential_field): + security_config = SecurityConfig.objects.filter(pk=1).first() + ttl_seconds = security_config.credential_ttl_seconds if security_config is not None else None + setattr(config, credential_field, secrets.token_urlsafe(32)) + config.credential_expires_at = ( + timezone.now() + timedelta(seconds=ttl_seconds) if ttl_seconds is not None else None + ) + config.save(update_fields=[credential_field, 'credential_expires_at']) + messages.success(request, _('The onboarding credential was renewed.')) + else: + messages.warning(request, _('The onboarding credential is not expired or is no longer available.')) + return HttpResponseRedirect(request.get_full_path()) + def _make_context(self, host_ip: str = '127.0.0.1') -> HelpContext: device = self.object domain = getattr(device, 'domain', None) @@ -165,6 +215,13 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: sections, heading = self.strategy.build_sections(help_context=help_context) + onboarding_config = self.object.onboarding_config + context['credential_expired'] = bool( + self.uses_onboarding_credential() + and onboarding_config + and onboarding_config.is_credential_expired() + ) + context['can_renew_credential'] = self.request.user.has_perm(AppPermissions.MANAGE_DEVICES) context['help_page'] = HelpPage(heading=heading, sections=sections) context['ValueRenderType_CODE'] = ValueRenderType.CODE.value context['ValueRenderType_PLAIN'] = ValueRenderType.PLAIN.value diff --git a/trustpoint/help_pages/tests/test_credential_expiry.py b/trustpoint/help_pages/tests/test_credential_expiry.py new file mode 100644 index 000000000..ab32a945a --- /dev/null +++ b/trustpoint/help_pages/tests/test_credential_expiry.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Tests for expired onboarding credentials on device help pages.""" + +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest +from django.test import RequestFactory +from django.utils import timezone +from django.views.generic.detail import DetailView + +from help_pages.devices_help_views import ( + AgentSetupProfileStrategy, + ApplicationCertificateWithCmpDomainCredentialStrategy, + BaseHelpView, + NoOnboardingCmpSharedSecretStrategy, + OnboardingDomainCredentialCmpSharedSecretStrategy, + OnboardingDomainCredentialEstUsernamePasswordStrategy, + OnboardingDomainCredentialRestUsernamePasswordStrategy, +) +from onboarding.models import OnboardingConfigModel + + +@pytest.mark.parametrize('strategy_class', [ + OnboardingDomainCredentialCmpSharedSecretStrategy, + OnboardingDomainCredentialEstUsernamePasswordStrategy, + OnboardingDomainCredentialRestUsernamePasswordStrategy, + AgentSetupProfileStrategy, + ApplicationCertificateWithCmpDomainCredentialStrategy, + NoOnboardingCmpSharedSecretStrategy, +]) +@pytest.mark.parametrize('expiry_offset', [None, -1, 0, 1]) +def test_help_warns_only_for_expired_onboarding_credentials(strategy_class, expiry_offset) -> None: + now = timezone.now() + config = OnboardingConfigModel( + credential_expires_at=None if expiry_offset is None else now + timedelta(seconds=expiry_offset) + ) + view = BaseHelpView() + view.object = Mock(onboarding_config=config) + view.strategy = strategy_class() + view.page_name = 'devices' + view.request = RequestFactory().get('/') + view.request.user = Mock() + with patch.object(DetailView, 'get_context_data', return_value={}), \ + patch.object(view, '_make_context'), \ + patch.object(view.strategy, 'build_sections', return_value=([], 'Help')), \ + patch('help_pages.devices_help_views.ActiveTrustpointTlsServerCredentialModel.objects.get'), \ + patch('onboarding.models.timezone.now', return_value=now): + context = view.get_context_data() + + password_strategy = strategy_class not in ( + ApplicationCertificateWithCmpDomainCredentialStrategy, NoOnboardingCmpSharedSecretStrategy + ) + assert context['credential_expired'] is ( + password_strategy and expiry_offset is not None and expiry_offset <= 0 + ) \ No newline at end of file diff --git a/trustpoint/help_pages/tests/test_devices_help_views.py b/trustpoint/help_pages/tests/test_devices_help_views.py index 87216c06e..7a1a60e45 100644 --- a/trustpoint/help_pages/tests/test_devices_help_views.py +++ b/trustpoint/help_pages/tests/test_devices_help_views.py @@ -233,6 +233,8 @@ def test_get_context_data_success( self.view.page_category = 'devices' self.view.page_name = 'devices' request = self.factory.get('/') + request.user = Mock() + request.user.has_perm.return_value = True self.view.request = request with patch('help_pages.devices_help_views.settings') as mock_settings: @@ -241,6 +243,7 @@ def test_get_context_data_success( assert 'help_page' in context assert context['help_page'].heading == 'Test Heading' + assert context['can_renew_credential'] is True assert context['ValueRenderType_CODE'] == ValueRenderType.CODE.value assert context['clm_url'] == 'devices:devices_certificate_lifecycle_management' diff --git a/trustpoint/management/forms.py b/trustpoint/management/forms.py index 2ecb95957..df0da217a 100644 --- a/trustpoint/management/forms.py +++ b/trustpoint/management/forms.py @@ -190,6 +190,7 @@ def __init__(self, *args: Any, **kwargs: Any)-> None: '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'), @@ -251,6 +252,7 @@ class Meta: fields: ClassVar[list[str]] = [ 'security_mode', 'auto_gen_pki', 'auto_gen_pki_key_algorithm', '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', 'allow_imported_private_keys', 'permitted_no_onboarding_pki_protocols', @@ -329,7 +331,7 @@ 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 - for field in ['max_cert_validity_days', 'max_crl_validity_days']: + for field in ['max_cert_validity_days', 'max_crl_validity_days', 'credential_ttl_seconds']: val = cleaned.get(field) default_val = defaults.get(field) if default_val is not None and (val is None or val > default_val): diff --git a/trustpoint/management/migrations/0003_tp_v1_0_dev0.py b/trustpoint/management/migrations/0003_tp_v1_0_dev0.py index 973f19638..feb7cfad3 100644 --- a/trustpoint/management/migrations/0003_tp_v1_0_dev0.py +++ b/trustpoint/management/migrations/0003_tp_v1_0_dev0.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 The Trustpoint Project Authors # SPDX-License-Identifier: MIT +import django.core.validators from django.db import migrations, models @@ -36,6 +37,11 @@ class Migration(migrations.Migration): migrations.DeleteModel( name='UIConfig', ), + migrations.AddField( + model_name='securityconfig', + name='credential_ttl_seconds', + field=models.PositiveIntegerField(blank=True, default=None, help_text='Default lifetime in seconds for CMP, EST, and REST onboarding credentials. Blank means no expiry.', null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), migrations.AddField( model_name='securityconfig', name='not_permitted_mldsa_variant_oids', diff --git a/trustpoint/management/models/security.py b/trustpoint/management/models/security.py index df990ce2c..7bf4fb945 100644 --- a/trustpoint/management/models/security.py +++ b/trustpoint/management/models/security.py @@ -8,6 +8,7 @@ import json from typing import TYPE_CHECKING, ClassVar, TypedDict, cast +from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ from trustpoint_core.oid import AlgorithmIdentifier, HashAlgorithm, NamedCurve, PublicKeyAlgorithmOid @@ -29,6 +30,7 @@ class _SecurityModeDefaults(TypedDict): not_permitted_signature_algorithm_oids: list[str] max_cert_validity_days: int | None max_crl_validity_days: int | None + credential_ttl_seconds: int | None allow_ca_issuance: bool allow_auto_gen_pki: bool allow_self_signed_ca: bool @@ -181,6 +183,16 @@ class MlDsaVariantChoices(models.TextChoices): ), ) + credential_ttl_seconds = models.PositiveIntegerField( + null=True, + blank=True, + default=None, + validators=[MinValueValidator(1)], + help_text=_( + 'Default lifetime in seconds for CMP, EST, and REST onboarding credentials. Blank means no expiry.' + ), + ) + # -- Certificate policy flags ----------------------------------------- allow_ca_issuance = models.BooleanField( @@ -248,6 +260,7 @@ class MlDsaVariantChoices(models.TextChoices): 'not_permitted_signature_algorithm_oids': [], 'max_cert_validity_days': None, # no limit 'max_crl_validity_days': None, # no limit + 'credential_ttl_seconds': None, 'allow_ca_issuance': True, 'allow_auto_gen_pki': True, 'allow_self_signed_ca': True, @@ -266,6 +279,7 @@ class MlDsaVariantChoices(models.TextChoices): 'not_permitted_signature_algorithm_oids': [], 'max_cert_validity_days': 1825, # 5 years 'max_crl_validity_days': 365, + 'credential_ttl_seconds': None, 'allow_ca_issuance': False, 'allow_auto_gen_pki': True, 'allow_self_signed_ca': True, @@ -290,6 +304,7 @@ class MlDsaVariantChoices(models.TextChoices): ], 'max_cert_validity_days': 365, 'max_crl_validity_days': 180, + 'credential_ttl_seconds': None, 'allow_ca_issuance': False, 'allow_auto_gen_pki': False, 'allow_self_signed_ca': False, @@ -316,6 +331,7 @@ class MlDsaVariantChoices(models.TextChoices): ], 'max_cert_validity_days': 365, 'max_crl_validity_days': 90, + 'credential_ttl_seconds': 600, 'allow_ca_issuance': False, 'allow_auto_gen_pki': False, 'allow_self_signed_ca': False, @@ -344,6 +360,7 @@ class MlDsaVariantChoices(models.TextChoices): ], 'max_cert_validity_days': 180, 'max_crl_validity_days': 90, + 'credential_ttl_seconds': 600, 'allow_ca_issuance': False, 'allow_auto_gen_pki': False, 'allow_self_signed_ca': False, @@ -373,6 +390,7 @@ def apply_security_settings(self, *, save: bool = True) -> None: self.not_permitted_signature_algorithm_oids = list(defaults['not_permitted_signature_algorithm_oids']) self.max_cert_validity_days = defaults['max_cert_validity_days'] self.max_crl_validity_days = defaults['max_crl_validity_days'] + self.credential_ttl_seconds = defaults['credential_ttl_seconds'] self.allow_ca_issuance = defaults['allow_ca_issuance'] self.allow_auto_gen_pki = defaults['allow_auto_gen_pki'] self.allow_self_signed_ca = defaults['allow_self_signed_ca'] @@ -388,6 +406,7 @@ def apply_security_settings(self, *, save: bool = True) -> None: 'not_permitted_signature_algorithm_oids', 'max_cert_validity_days', 'max_crl_validity_days', + 'credential_ttl_seconds', 'allow_ca_issuance', 'allow_auto_gen_pki', 'allow_self_signed_ca', @@ -422,6 +441,7 @@ def get_settings_preview_json(cls) -> str: ], 'max_cert_validity_days': defaults['max_cert_validity_days'], 'max_crl_validity_days': defaults['max_crl_validity_days'], + 'credential_ttl_seconds': defaults['credential_ttl_seconds'], 'allow_ca_issuance': defaults['allow_ca_issuance'], 'allow_auto_gen_pki': defaults['allow_auto_gen_pki'], 'allow_self_signed_ca': defaults['allow_self_signed_ca'], diff --git a/trustpoint/management/security_env.py b/trustpoint/management/security_env.py index e6088d28a..d965c7027 100644 --- a/trustpoint/management/security_env.py +++ b/trustpoint/management/security_env.py @@ -49,6 +49,7 @@ class SecurityRestrictions: rsa_minimum_key_size: int | _Unset | None = UNSET max_cert_validity_days: int | _Unset | None = UNSET max_crl_validity_days: int | _Unset | None = UNSET + credential_ttl_seconds: int | _Unset | None = UNSET allow_ca_issuance: bool | _Unset = UNSET allow_auto_gen_pki: bool | _Unset = UNSET allow_self_signed_ca: bool | _Unset = UNSET @@ -118,7 +119,14 @@ def _parse_protocols( def parse_security_restrictions(environment: Mapping[str, str] | None = None) -> SecurityRestrictions: """Parse all optional ``TP_SECURITY_*`` restrictions.""" + credential_ttl_seconds = _parse_nullable_integer( + 'TP_SECURITY_CREDENTIAL_TTL_SECONDS', + _raw_environment(environment, 'TP_SECURITY_CREDENTIAL_TTL_SECONDS'), + ) + if credential_ttl_seconds == 0: + _fail('TP_SECURITY_CREDENTIAL_TTL_SECONDS must be a positive integer or null.') return SecurityRestrictions( + credential_ttl_seconds=credential_ttl_seconds, rsa_minimum_key_size=_parse_nullable_integer( 'TP_SECURITY_RSA_MINIMUM_KEY_SIZE', _raw_environment(environment, 'TP_SECURITY_RSA_MINIMUM_KEY_SIZE'), @@ -204,7 +212,7 @@ def effective_security_defaults( ) restrictions = restrictions or parse_security_restrictions() - for field in ('rsa_minimum_key_size', 'max_cert_validity_days', 'max_crl_validity_days'): + for field in ('rsa_minimum_key_size', 'max_cert_validity_days', 'max_crl_validity_days', 'credential_ttl_seconds'): value = getattr(restrictions, field) if _is_unset(value): continue @@ -309,6 +317,7 @@ def synchronize_security_config() -> bool: 'rsa_minimum_key_size', 'max_cert_validity_days', 'max_crl_validity_days', + 'credential_ttl_seconds', } ): setattr(config, field, value) diff --git a/trustpoint/management/tests/test_forms/test_security_config.py b/trustpoint/management/tests/test_forms/test_security_config.py index d41fc92f7..b15054ad6 100644 --- a/trustpoint/management/tests/test_forms/test_security_config.py +++ b/trustpoint/management/tests/test_forms/test_security_config.py @@ -69,6 +69,7 @@ def test_form_with_high_security_mode(self): 'rsa_minimum_key_size': 4096, 'max_cert_validity_days': 365, 'max_crl_validity_days': 90, + 'credential_ttl_seconds': 600, 'allow_ca_issuance': False, 'allow_auto_gen_pki': False, 'allow_self_signed_ca': False, @@ -175,6 +176,7 @@ def test_all_security_modes(self): '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'], + 'credential_ttl_seconds': defaults['credential_ttl_seconds'], 'allow_ca_issuance': defaults['allow_ca_issuance'], 'allow_auto_gen_pki': defaults['allow_auto_gen_pki'], 'allow_self_signed_ca': defaults['allow_self_signed_ca'], @@ -206,6 +208,31 @@ def test_form_saves_imported_private_key_policy(self) -> None: assert saved.allow_imported_private_keys +@pytest.mark.django_db +@pytest.mark.parametrize('mode', list(SecurityConfig.SecurityModeChoices)) +@pytest.mark.parametrize('ttl_seconds', [None, 0, 300, 600, 601]) +def test_credential_ttl_form_policy(mode: str, ttl_seconds: int | None) -> None: + """The form permits custom TTLs without weakening the hardened presets.""" + defaults = SecurityConfig._MODE_DEFAULTS[mode] + config = SecurityConfig.objects.create(security_mode=mode) + form = SecurityConfigForm(data={ + 'security_mode': mode, + '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'], + 'credential_ttl_seconds': ttl_seconds, + }, instance=config) + hardened = mode in (SecurityConfig.SecurityModeChoices.HARDENED, SecurityConfig.SecurityModeChoices.CRITICAL) + valid = ttl_seconds != 0 and (not hardened or (ttl_seconds is not None and ttl_seconds <= 600)) + assert form.is_valid() == valid, form.errors + if valid: + form.save() + config.refresh_from_db() + assert config.credential_ttl_seconds == ttl_seconds + else: + assert 'credential_ttl_seconds' in form.errors + + @pytest.mark.django_db def test_protocol_allowlists_are_saved_as_int_lists() -> None: """Protocol allow-lists from multi-select fields are normalized to integer lists on save.""" diff --git a/trustpoint/management/tests/test_security_env.py b/trustpoint/management/tests/test_security_env.py index b7020263a..246b9bc4b 100644 --- a/trustpoint/management/tests/test_security_env.py +++ b/trustpoint/management/tests/test_security_env.py @@ -24,6 +24,40 @@ class SecurityEnvironmentTest(TestCase): """Test parsing and monotonic application of security restrictions.""" + def test_credential_ttl_environment_policy(self) -> None: + for mode in SecurityConfig.SecurityModeChoices: + hardened = mode in (SecurityConfig.SecurityModeChoices.HARDENED, SecurityConfig.SecurityModeChoices.CRITICAL) + with self.subTest(mode=mode): + defaults = effective_security_defaults(mode, parse_security_restrictions({})) + self.assertEqual(defaults['credential_ttl_seconds'], 600 if hardened else None) + for value in ('300', '600', '601', 'null'): + restrictions = parse_security_restrictions({'TP_SECURITY_CREDENTIAL_TTL_SECONDS': value}) + if hardened and value in ('601', 'null'): + with self.assertRaisesRegex(SecurityConfigurationError, 'credential_ttl_seconds'): + effective_security_defaults(mode, restrictions) + else: + defaults = effective_security_defaults(mode, restrictions) + self.assertEqual(defaults['credential_ttl_seconds'], None if value == 'null' else int(value)) + + def test_invalid_credential_ttl_is_rejected(self) -> None: + for value in ('0', '-1', '', 'invalid', '1.5'): + with self.subTest(value=value), self.assertRaisesRegex( + SecurityConfigurationError, 'TP_SECURITY_CREDENTIAL_TTL_SECONDS', + ): + parse_security_restrictions({'TP_SECURITY_CREDENTIAL_TTL_SECONDS': value}) + + def test_credential_ttl_synchronization(self) -> None: + for value, expected in (('300', 300), ('null', None)): + with self.subTest(value=value), patch.dict('os.environ', { + 'TP_SECURITY_MODE': 'LAB', + 'TP_SECURITY_CREDENTIAL_TTL_SECONDS': value, + }, clear=True): + self.assertTrue(synchronize_security_config()) + self.assertEqual(SecurityConfig.objects.get(pk=1).credential_ttl_seconds, expected) + with patch.dict('os.environ', {'TP_SECURITY_MODE': 'HARDENED'}, clear=True): + self.assertTrue(synchronize_security_config()) + self.assertEqual(SecurityConfig.objects.get(pk=1).credential_ttl_seconds, 600) + def test_security_mode_names_are_mapped(self) -> None: for name, choice in SecurityConfig.SecurityModeChoices.__members__.items(): self.assertEqual(security_mode_from_environment({'TP_SECURITY_MODE': name}), choice.value) diff --git a/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py b/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py new file mode 100644 index 000000000..786a1dfa3 --- /dev/null +++ b/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('onboarding', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingconfigmodel', + name='credential_expires_at', + field=models.DateTimeField(blank=True, default=None, help_text='Expiry for CMP shared-secret and EST/REST password authentication. Null means no expiry.', null=True, verbose_name='Credential Expiry'), + ), + ] diff --git a/trustpoint/onboarding/models.py b/trustpoint/onboarding/models.py index a35306253..f452bb76b 100644 --- a/trustpoint/onboarding/models.py +++ b/trustpoint/onboarding/models.py @@ -5,10 +5,13 @@ from __future__ import annotations +from datetime import timedelta from typing import TYPE_CHECKING +from django.apps import apps from django.core.exceptions import ValidationError from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from onboarding.enums import ( @@ -114,6 +117,14 @@ class OnboardingConfigModel(AbstractPkiProtocolModel[OnboardingPkiProtocol], mod est_password = EncryptedCharField(verbose_name=_('EST Password'), max_length=128, blank=True, default='') cmp_shared_secret = EncryptedCharField(verbose_name=_('CMP Shared Secret'), max_length=128, blank=True, default='') + credential_expires_at = models.DateTimeField( + verbose_name=_('Credential Expiry'), + null=True, + blank=True, + default=None, + help_text=_('Expiry for CMP shared-secret and EST/REST password authentication. Null means no expiry.'), + ) + opc_user = models.CharField(verbose_name=_('OPC User'), max_length=128, blank=True, default='') opc_password = EncryptedCharField(verbose_name=_('OPC Password'), max_length=128, blank=True, default='') @@ -162,6 +173,20 @@ def save(self, *args: Any, **kwargs: Any) -> None: *args: Positional arguments are passed to super().save(). **kwargs: Keyword arguments are passed to super().save(). """ + if ( + self._state.adding + and self.credential_expires_at is None + and self.onboarding_protocol in ( + OnboardingProtocol.CMP_SHARED_SECRET, + OnboardingProtocol.EST_USERNAME_PASSWORD, + OnboardingProtocol.REST_USERNAME_PASSWORD, + OnboardingProtocol.AGENT, + ) + and (self.cmp_shared_secret or self.est_password) + ): + security_config = apps.get_model('management', 'SecurityConfig').objects.filter(pk=1).first() + if security_config is not None and security_config.credential_ttl_seconds is not None: + self.credential_expires_at = timezone.now() + timedelta(seconds=security_config.credential_ttl_seconds) self.full_clean() super().save(*args, **kwargs) @@ -171,6 +196,17 @@ def clean(self) -> None: if error_messages: raise ValidationError(error_messages) + def is_credential_expired(self) -> bool: + """Checks whether the shared authentication credential has expired. + + Returns: + True if an expiry is set and the current time is past it, False otherwise + (including when no expiry is configured). + """ + if self.credential_expires_at is None: + return False + return timezone.now() >= self.credential_expires_at + def _dispatch_protocol_validation(self) -> dict[str, str]: """Dispatch validation to the appropriate handler based on the onboarding protocol.""" match self.onboarding_protocol: @@ -345,14 +381,10 @@ def _validate_case_agent_onboarding(self) -> dict[str, str]: error_messages = {} if self.cmp_shared_secret != '': - error_messages['cmp_shared_secret'] = ( - 'CMP shared-secret must not be set for Agent onboarding.' # noqa: S105 - ) + error_messages['cmp_shared_secret'] = 'CMP shared-secret must not be set for Agent onboarding.' # noqa: S105 if self.idevid_trust_store is not None: - error_messages['idevid_trust_store'] = ( - 'IDevID truststore must not be set for Agent onboarding.' - ) + error_messages['idevid_trust_store'] = 'IDevID truststore must not be set for Agent onboarding.' allowed_protocols = self.get_pki_protocols() if OnboardingPkiProtocol.REST not in allowed_protocols: diff --git a/trustpoint/pki/migrations/0003_tp_v1_0_dev0.py b/trustpoint/pki/migrations/0003_tp_v1_0_dev0.py index bdff129d9..3ea7f10b8 100644 --- a/trustpoint/pki/migrations/0003_tp_v1_0_dev0.py +++ b/trustpoint/pki/migrations/0003_tp_v1_0_dev0.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('onboarding', '0002_initial'), + ('onboarding', '0003_tp_v1_0_dev0'), ('pki', '0002_initial'), ] diff --git a/trustpoint/request/authentication/cmp.py b/trustpoint/request/authentication/cmp.py index 98168d722..7af6532b3 100644 --- a/trustpoint/request/authentication/cmp.py +++ b/trustpoint/request/authentication/cmp.py @@ -253,6 +253,14 @@ def _validate_device_configuration(self, device: DeviceModel, sender_kid: int self.logger.warning( 'Device %s (ID: %s) has no CMP shared secret configured', device.common_name, sender_kid) self._raise_cmp_error(error_message) + + if isinstance(device_config, OnboardingConfigModel) and device_config.is_credential_expired(): + error_message = 'CMP shared secret authentication failed: Shared secret has expired.' + self.logger.warning( + 'Device %s (ID: %s) presented an expired CMP shared secret (expired at %s)', + device.common_name, sender_kid, device_config.credential_expires_at) + self._raise_cmp_error(error_message) + return device_config def _verify_hmac_protection(self, context: CmpBaseRequestContext, shared_secret: str) -> None: diff --git a/trustpoint/request/authentication/est.py b/trustpoint/request/authentication/est.py index f143955bd..8788fc9ed 100644 --- a/trustpoint/request/authentication/est.py +++ b/trustpoint/request/authentication/est.py @@ -8,6 +8,7 @@ from cryptography import x509 from devices.models import DeviceModel +from onboarding.models import OnboardingConfigModel from pki.models import CredentialModel, IssuedCredentialModel from request.request_context import BaseRequestContext, EstBaseRequestContext, EstCertificateRequestContext from trustpoint.logger import LoggerMixin @@ -56,6 +57,10 @@ def authenticate(self, context: BaseRequestContext) -> None: self.logger.warning('Authentication failed: No EST password set for %s', username) self._raise_authentication_error() + if isinstance(device_config, OnboardingConfigModel) and device_config.is_credential_expired(): + self.logger.warning('Authentication failed: Credential has expired for %s', username) + self._raise_authentication_error() + # Use proper password hashing instead of plaintext comparison if password != device_config.est_password: self.logger.warning('Authentication failed: Invalid password for %s', username) diff --git a/trustpoint/request/authentication/rest.py b/trustpoint/request/authentication/rest.py index a75ca7964..e44614808 100644 --- a/trustpoint/request/authentication/rest.py +++ b/trustpoint/request/authentication/rest.py @@ -6,6 +6,7 @@ from typing import Never from devices.models import DeviceModel +from onboarding.models import OnboardingConfigModel from request.request_context import BaseRequestContext, RestBaseRequestContext from trustpoint.logger import LoggerMixin @@ -53,6 +54,10 @@ def authenticate(self, context: BaseRequestContext) -> None: self.logger.warning('REST authentication failed: No password set for %s', username) self._raise_authentication_error() + if isinstance(device_config, OnboardingConfigModel) and device_config.is_credential_expired(): + self.logger.warning('REST authentication failed: Credential has expired for %s', username) + self._raise_authentication_error() + if password != device_config.est_password: self.logger.warning('REST authentication failed: Invalid password for %s', username) self._raise_authentication_error() diff --git a/trustpoint/request/tests/test_authentication_extended.py b/trustpoint/request/tests/test_authentication_extended.py index e28776d4b..ff337af3a 100644 --- a/trustpoint/request/tests/test_authentication_extended.py +++ b/trustpoint/request/tests/test_authentication_extended.py @@ -43,6 +43,7 @@ ReenrollmentAuthentication, UsernamePasswordAuthentication, ) +from request.authentication.rest import RestUsernamePasswordAuthentication from request.request_context import ( BaseRequestContext, BaseCertificateRequestContext, @@ -52,6 +53,7 @@ EstBaseRequestContext, EstCertificateRequestContext, HttpBaseRequestContext, + RestBaseRequestContext, ) @@ -100,6 +102,44 @@ def _cmp_message_with_certificate(certificate: x509.Certificate) -> rfc4210.PKIM class TestUsernamePasswordAuthenticationExtended: """Extended tests for UsernamePasswordAuthentication.""" + @pytest.mark.parametrize('protocol', ['est', 'rest']) + @pytest.mark.parametrize('config_class', [OnboardingConfigModel, NoOnboardingConfigModel]) + @pytest.mark.parametrize('expiry_offset', [None, -1, 0, 1]) + def test_password_credential_expiry( + self, protocol: str, + config_class: type[OnboardingConfigModel] | type[NoOnboardingConfigModel], + expiry_offset: int | None, + ) -> None: + """EST and REST enforce expiry only for onboarding configurations.""" + now = datetime(2026, 10, 2, tzinfo=timezone.utc) + expiry = None if expiry_offset is None else now + timedelta(seconds=expiry_offset) + config = config_class(est_password='test-secret') + if isinstance(config, OnboardingConfigModel): + config.credential_expires_at = expiry + device = DeviceModel(common_name='device') + if config_class is OnboardingConfigModel: + device.onboarding_config = config + else: + device.no_onboarding_config = config + + if protocol == 'est': + context = EstBaseRequestContext(est_username='device', est_password='test-secret') + auth = UsernamePasswordAuthentication() + else: + context = RestBaseRequestContext(rest_username='device', rest_password='test-secret') + auth = RestUsernamePasswordAuthentication() + + with patch('onboarding.models.timezone.now', return_value=now), \ + patch.object(DeviceModel.objects, 'select_related') as select_related: + select_related.return_value.filter.return_value.first.return_value = device + if isinstance(config, OnboardingConfigModel) and expiry_offset is not None and expiry_offset <= 0: + with pytest.raises(ValueError, match='Invalid username or password'): + auth.authenticate(context) + assert context.device is None + else: + auth.authenticate(context) + assert context.device is device + def test_authenticate_rejects_non_base_est_context(self) -> None: """Test authentication requires the EST base context contract.""" with pytest.raises(TypeError, match='requires an EstBaseRequestContext'): @@ -385,6 +425,32 @@ def test_authenticate_invalid_domain_credential( @pytest.mark.django_db class TestCmpSharedSecretAuthentication: """Tests for CMP Shared Secret Authentication.""" + + @pytest.mark.parametrize('config_class', [OnboardingConfigModel, NoOnboardingConfigModel]) + @pytest.mark.parametrize('expiry_offset', [None, -1, 0, 1]) + def test_shared_secret_expiry_for_both_configurations( + self, config_class: type[OnboardingConfigModel] | type[NoOnboardingConfigModel], + expiry_offset: int | None, + ) -> None: + """Both configurations reject secrets at or after expiry, but allow unset expiry.""" + now = datetime(2026, 10, 2, tzinfo=timezone.utc) + expiry = None if expiry_offset is None else now + timedelta(seconds=expiry_offset) + config = config_class(cmp_shared_secret='test-secret') + if isinstance(config, OnboardingConfigModel): + config.credential_expires_at = expiry + device = Mock( + common_name='device', + onboarding_config=config if config_class is OnboardingConfigModel else None, + no_onboarding_config=config if config_class is NoOnboardingConfigModel else None, + ) + auth = CmpSharedSecretAuthentication() + + with patch('onboarding.models.timezone.now', return_value=now): + if isinstance(config, OnboardingConfigModel) and expiry_offset is not None and expiry_offset <= 0: + with pytest.raises(ValueError, match='Shared secret has expired'): + auth._validate_device_configuration(device, sender_kid=7) + else: + assert auth._validate_device_configuration(device, sender_kid=7) is config def test_authenticate_non_cmp_protocol(self) -> None: """Test authentication raises error when protocol is not CMP.""" diff --git a/trustpoint/templates/help/help_page.html b/trustpoint/templates/help/help_page.html index 7e061a6a1..05bcf07f7 100644 --- a/trustpoint/templates/help/help_page.html +++ b/trustpoint/templates/help/help_page.html @@ -80,6 +80,20 @@

+ {% if credential_expired %} + + {% endif %} + {# ======================================================== #} {# Command Configuration #} {# ======================================================== #} diff --git a/trustpoint/templates/management/includes/security_configuration.html b/trustpoint/templates/management/includes/security_configuration.html index 459e1ccab..1bc1019a0 100644 --- a/trustpoint/templates/management/includes/security_configuration.html +++ b/trustpoint/templates/management/includes/security_configuration.html @@ -229,7 +229,8 @@

try { [ 'max_cert_validity_days', - 'max_crl_validity_days' + 'max_crl_validity_days', + 'credential_ttl_seconds' ].forEach(field => { const input = document.querySelector( @@ -446,7 +447,8 @@

try { [ 'max_cert_validity_days', - 'max_crl_validity_days' + 'max_crl_validity_days', + 'credential_ttl_seconds' ].forEach(field => { const input = document.querySelector(