From 15d699d657b65f9c6893a6f2d00c155a81445425 Mon Sep 17 00:00:00 2001 From: Andreas Walden Date: Thu, 1 Oct 2026 09:37:32 +0200 Subject: [PATCH 1/7] feat: Add cmp_shared_secret_expires_at for time-limited challenge password - onboarding/models.py: cmp_shared_secret_expires_at field + migration 0003 - devices/serializers.py: expose/accept expiry and TTL - request/authentication/cmp.py: reject shared secret after expiry - nginx: config adjustment --- trustpoint/devices/serializers.py | 58 +++++++++++++++++++ ...onfigmodel_cmp_shared_secret_expires_at.py | 26 +++++++++ trustpoint/onboarding/models.py | 19 ++++++ trustpoint/request/authentication/cmp.py | 10 ++++ 4 files changed, 113 insertions(+) create mode 100644 trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py diff --git a/trustpoint/devices/serializers.py b/trustpoint/devices/serializers.py index eff78061f..ada343264 100644 --- a/trustpoint/devices/serializers.py +++ b/trustpoint/devices/serializers.py @@ -8,14 +8,22 @@ """ 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 from .models import DeviceModel +# Default validity window for an auto-generated CMP shared secret (challenge password) +# when the caller does not specify an explicit expiry or TTL. Ten minutes is enough +# for an operator to provision the secret to a device without leaving a long-lived +# credential around. +DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS = 600 + class PkiProtocolField(serializers.Field[int | str, int | str, int | str, Any]): """Custom field to accept PKI protocol as string name or integer value.""" @@ -53,6 +61,19 @@ class OnboardingConfigSerializer(serializers.ModelSerializer[OnboardingConfigMod ) ) + cmp_shared_secret_ttl_seconds = serializers.IntegerField( + required=False, + write_only=True, + min_value=1, + help_text=( + 'Optional validity window (in seconds) for the CMP shared secret. ' + 'When provided, cmp_shared_secret_expires_at is set to now + ttl. ' + 'If omitted for CMP_SHARED_SECRET onboarding, a default TTL is applied ' + f'({DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS}s) whenever an expiry is not ' + 'explicitly given.' + ), + ) + class Meta: """Metadata for OnboardingConfigSerializer.""" @@ -64,6 +85,8 @@ class Meta: 'pki_protocols', 'est_password', 'cmp_shared_secret', + 'cmp_shared_secret_expires_at', + 'cmp_shared_secret_ttl_seconds', 'opc_user', 'opc_password', 'idevid_trust_store', @@ -87,6 +110,32 @@ class Meta: }, } + def _apply_shared_secret_expiry( + self, + data: dict[str, Any], + onboarding_protocol: Any, + ttl_seconds: int | None, + ) -> None: + """Resolve the CMP shared-secret expiry into data['cmp_shared_secret_expires_at']. + + Precedence: + 1. An explicit cmp_shared_secret_expires_at already in data is left untouched. + 2. An explicit ttl_seconds sets expiry to now + ttl. + 3. For CMP_SHARED_SECRET onboarding with a secret set and no expiry, a default + TTL is applied so challenge passwords are time-limited by default. + """ + if data.get('cmp_shared_secret_expires_at') is not None: + return + + if ttl_seconds is not None: + data['cmp_shared_secret_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) + return + + if onboarding_protocol == OnboardingProtocol.CMP_SHARED_SECRET and data.get('cmp_shared_secret'): + data['cmp_shared_secret_expires_at'] = timezone.now() + timedelta( + seconds=DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS + ) + def _generate_secure_secret(self, length: int = 32) -> str: """Generate a cryptographically secure random secret. @@ -138,6 +187,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('cmp_shared_secret_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 +197,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_shared_secret_expiry(validated_data, onboarding_protocol, ttl_seconds) + instance = OnboardingConfigModel(**validated_data) if pki_protocol_values: @@ -159,6 +211,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('cmp_shared_secret_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['cmp_shared_secret_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py b/trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py new file mode 100644 index 000000000..e7caf6c6b --- /dev/null +++ b/trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025 The Trustpoint Project Authors +# SPDX-License-Identifier: MIT + +"""Adds an optional expiry timestamp for the CMP shared secret (challenge password).""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('onboarding', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingconfigmodel', + name='cmp_shared_secret_expires_at', + field=models.DateTimeField( + blank=True, + default=None, + null=True, + verbose_name='CMP Shared Secret Expiry', + ), + ), + ] diff --git a/trustpoint/onboarding/models.py b/trustpoint/onboarding/models.py index a35306253..d53722566 100644 --- a/trustpoint/onboarding/models.py +++ b/trustpoint/onboarding/models.py @@ -114,6 +114,13 @@ 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='') + # Optional expiry for the CMP shared secret (challenge password). When set, the + # shared secret is only accepted for CMP authentication before this timestamp. + # Null means the secret never expires (legacy behaviour). + cmp_shared_secret_expires_at = models.DateTimeField( + verbose_name=_('CMP Shared Secret Expiry'), null=True, blank=True, default=None + ) + 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='') @@ -155,6 +162,18 @@ def __str__(self) -> str: f'est_password:{bool(self.est_password)})' ) + def is_cmp_shared_secret_expired(self) -> bool: + """Checks whether the CMP shared secret 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.cmp_shared_secret_expires_at is None: + return False + from django.utils import timezone + return timezone.now() >= self.cmp_shared_secret_expires_at + def save(self, *args: Any, **kwargs: Any) -> None: """Executes full_clean() before saving. diff --git a/trustpoint/request/authentication/cmp.py b/trustpoint/request/authentication/cmp.py index 98168d722..b2ab27f4e 100644 --- a/trustpoint/request/authentication/cmp.py +++ b/trustpoint/request/authentication/cmp.py @@ -253,6 +253,16 @@ 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) + + # Enforce optional expiry (TTL) on the CMP shared secret. Only OnboardingConfigModel + # carries an expiry; NoOnboardingConfigModel secrets never expire. + if isinstance(device_config, OnboardingConfigModel) and device_config.is_cmp_shared_secret_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.cmp_shared_secret_expires_at) + self._raise_cmp_error(error_message) + return device_config def _verify_hmac_protection(self, context: CmpBaseRequestContext, shared_secret: str) -> None: From 7b315817d98f550e5374c8704539e66fc587b75a Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 09:53:13 +0200 Subject: [PATCH 2/7] updated migrations --- ...ared_secret_expires_at.py => 0003_tp_v1_0_dev0.py} | 11 ++--------- trustpoint/pki/migrations/0003_tp_v1_0_dev0.py | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) rename trustpoint/onboarding/migrations/{0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py => 0003_tp_v1_0_dev0.py} (50%) diff --git a/trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py b/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py similarity index 50% rename from trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py rename to trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py index e7caf6c6b..445cc3a84 100644 --- a/trustpoint/onboarding/migrations/0003_onboardingconfigmodel_cmp_shared_secret_expires_at.py +++ b/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py @@ -1,8 +1,6 @@ -# Copyright (c) 2025 The Trustpoint Project Authors +# Copyright (c) 2026 The Trustpoint Project Authors # SPDX-License-Identifier: MIT -"""Adds an optional expiry timestamp for the CMP shared secret (challenge password).""" - from django.db import migrations, models @@ -16,11 +14,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='onboardingconfigmodel', name='cmp_shared_secret_expires_at', - field=models.DateTimeField( - blank=True, - default=None, - null=True, - verbose_name='CMP Shared Secret Expiry', - ), + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='CMP Shared Secret Expiry'), ), ] 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'), ] From 91bc7d0fbd859df7ac473c4d1cd0b7c46325b2ab Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 11:05:08 +0200 Subject: [PATCH 3/7] Refactor credential management and expiry handling --- .env.example | 1 + trustpoint/devices/serializers.py | 48 +++---- .../tests/test_api/test_credential_expiry.py | 117 ++++++++++++++++++ trustpoint/help_pages/devices_help_views.py | 58 ++++++++- .../tests/test_credential_expiry.py | 55 ++++++++ trustpoint/management/forms.py | 4 +- .../migrations/0003_tp_v1_0_dev0.py | 6 + trustpoint/management/models/security.py | 20 +++ trustpoint/management/security_env.py | 11 +- .../tests/test_forms/test_security_config.py | 27 ++++ .../management/tests/test_security_env.py | 34 +++++ .../migrations/0003_tp_v1_0_dev0.py | 4 +- trustpoint/onboarding/models.py | 59 +++++---- trustpoint/request/authentication/cmp.py | 6 +- trustpoint/request/authentication/est.py | 5 + trustpoint/request/authentication/rest.py | 5 + .../tests/test_authentication_extended.py | 66 ++++++++++ trustpoint/templates/help/help_page.html | 14 +++ .../includes/security_configuration.html | 6 +- 19 files changed, 481 insertions(+), 65 deletions(-) create mode 100644 trustpoint/devices/tests/test_api/test_credential_expiry.py create mode 100644 trustpoint/help_pages/tests/test_credential_expiry.py 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 ada343264..81f47e773 100644 --- a/trustpoint/devices/serializers.py +++ b/trustpoint/devices/serializers.py @@ -18,12 +18,6 @@ from .models import DeviceModel -# Default validity window for an auto-generated CMP shared secret (challenge password) -# when the caller does not specify an explicit expiry or TTL. Ten minutes is enough -# for an operator to provision the secret to a device without leaving a long-lived -# credential around. -DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS = 600 - class PkiProtocolField(serializers.Field[int | str, int | str, int | str, Any]): """Custom field to accept PKI protocol as string name or integer value.""" @@ -61,16 +55,15 @@ class OnboardingConfigSerializer(serializers.ModelSerializer[OnboardingConfigMod ) ) - cmp_shared_secret_ttl_seconds = serializers.IntegerField( + credential_ttl_seconds = serializers.IntegerField( required=False, write_only=True, min_value=1, help_text=( - 'Optional validity window (in seconds) for the CMP shared secret. ' - 'When provided, cmp_shared_secret_expires_at is set to now + ttl. ' - 'If omitted for CMP_SHARED_SECRET onboarding, a default TTL is applied ' - f'({DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS}s) whenever an expiry is not ' - 'explicitly given.' + '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.' ), ) @@ -85,8 +78,8 @@ class Meta: 'pki_protocols', 'est_password', 'cmp_shared_secret', - 'cmp_shared_secret_expires_at', - 'cmp_shared_secret_ttl_seconds', + 'credential_expires_at', + 'credential_ttl_seconds', 'opc_user', 'opc_password', 'idevid_trust_store', @@ -110,32 +103,25 @@ class Meta: }, } - def _apply_shared_secret_expiry( + def _apply_credential_expiry( self, data: dict[str, Any], - onboarding_protocol: Any, ttl_seconds: int | None, ) -> None: - """Resolve the CMP shared-secret expiry into data['cmp_shared_secret_expires_at']. + """Resolve the credential expiry into data['credential_expires_at']. Precedence: - 1. An explicit cmp_shared_secret_expires_at already in data is left untouched. + 1. An explicit credential_expires_at already in data is left untouched. 2. An explicit ttl_seconds sets expiry to now + ttl. - 3. For CMP_SHARED_SECRET onboarding with a secret set and no expiry, a default - TTL is applied so challenge passwords are time-limited by default. + 3. The model applies the security configuration default when saving a new credential. """ - if data.get('cmp_shared_secret_expires_at') is not None: + if data.get('credential_expires_at') is not None: return if ttl_seconds is not None: - data['cmp_shared_secret_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) + data['credential_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) return - if onboarding_protocol == OnboardingProtocol.CMP_SHARED_SECRET and data.get('cmp_shared_secret'): - data['cmp_shared_secret_expires_at'] = timezone.now() + timedelta( - seconds=DEFAULT_CMP_SHARED_SECRET_TTL_SECONDS - ) - def _generate_secure_secret(self, length: int = 32) -> str: """Generate a cryptographically secure random secret. @@ -187,7 +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('cmp_shared_secret_ttl_seconds', None) + 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'): @@ -197,7 +183,7 @@ 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_shared_secret_expiry(validated_data, onboarding_protocol, ttl_seconds) + self._apply_credential_expiry(validated_data, ttl_seconds) instance = OnboardingConfigModel(**validated_data) @@ -211,12 +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('cmp_shared_secret_ttl_seconds', 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['cmp_shared_secret_expires_at'] = timezone.now() + timedelta(seconds=ttl_seconds) + 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..d2a6d3080 --- /dev/null +++ b/trustpoint/devices/tests/test_api/test_credential_expiry.py @@ -0,0 +1,117 @@ +"""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/help_pages/devices_help_views.py b/trustpoint/help_pages/devices_help_views.py index d904695ae..f06a9d5a7 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,7 @@ if TYPE_CHECKING: from typing import Any + from django.http import HttpRequest from pki.models import CaModel, CredentialModel @@ -75,7 +82,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 +91,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 +214,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..15ce86ab4 --- /dev/null +++ b/trustpoint/help_pages/tests/test_credential_expiry.py @@ -0,0 +1,55 @@ +"""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/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 index 445cc3a84..786a1dfa3 100644 --- a/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py +++ b/trustpoint/onboarding/migrations/0003_tp_v1_0_dev0.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='onboardingconfigmodel', - name='cmp_shared_secret_expires_at', - field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='CMP Shared Secret Expiry'), + 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 d53722566..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,11 +117,12 @@ 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='') - # Optional expiry for the CMP shared secret (challenge password). When set, the - # shared secret is only accepted for CMP authentication before this timestamp. - # Null means the secret never expires (legacy behaviour). - cmp_shared_secret_expires_at = models.DateTimeField( - verbose_name=_('CMP Shared Secret Expiry'), null=True, blank=True, default=None + 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='') @@ -162,18 +166,6 @@ def __str__(self) -> str: f'est_password:{bool(self.est_password)})' ) - def is_cmp_shared_secret_expired(self) -> bool: - """Checks whether the CMP shared secret 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.cmp_shared_secret_expires_at is None: - return False - from django.utils import timezone - return timezone.now() >= self.cmp_shared_secret_expires_at - def save(self, *args: Any, **kwargs: Any) -> None: """Executes full_clean() before saving. @@ -181,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) @@ -190,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: @@ -364,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/request/authentication/cmp.py b/trustpoint/request/authentication/cmp.py index b2ab27f4e..7af6532b3 100644 --- a/trustpoint/request/authentication/cmp.py +++ b/trustpoint/request/authentication/cmp.py @@ -254,13 +254,11 @@ def _validate_device_configuration(self, device: DeviceModel, sender_kid: int 'Device %s (ID: %s) has no CMP shared secret configured', device.common_name, sender_kid) self._raise_cmp_error(error_message) - # Enforce optional expiry (TTL) on the CMP shared secret. Only OnboardingConfigModel - # carries an expiry; NoOnboardingConfigModel secrets never expire. - if isinstance(device_config, OnboardingConfigModel) and device_config.is_cmp_shared_secret_expired(): + 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.cmp_shared_secret_expires_at) + device.common_name, sender_kid, device_config.credential_expires_at) self._raise_cmp_error(error_message) return device_config 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( From 7f59e94c59d6ba519abe05c57922d77fc6dc7348 Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 11:07:05 +0200 Subject: [PATCH 4/7] fix ruff --- trustpoint/help_pages/devices_help_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/trustpoint/help_pages/devices_help_views.py b/trustpoint/help_pages/devices_help_views.py index f06a9d5a7..d4aa96100 100644 --- a/trustpoint/help_pages/devices_help_views.py +++ b/trustpoint/help_pages/devices_help_views.py @@ -72,6 +72,7 @@ from typing import Any from django.http import HttpRequest + from pki.models import CaModel, CredentialModel From 91e069d29c2a521aedd31d64cb5bd9c488db6c31 Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 11:08:38 +0200 Subject: [PATCH 5/7] Fix copyright header --- trustpoint/devices/tests/test_api/test_credential_expiry.py | 3 +++ trustpoint/help_pages/tests/test_credential_expiry.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/trustpoint/devices/tests/test_api/test_credential_expiry.py b/trustpoint/devices/tests/test_api/test_credential_expiry.py index d2a6d3080..041c15f52 100644 --- a/trustpoint/devices/tests/test_api/test_credential_expiry.py +++ b/trustpoint/devices/tests/test_api/test_credential_expiry.py @@ -1,3 +1,6 @@ +# 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 diff --git a/trustpoint/help_pages/tests/test_credential_expiry.py b/trustpoint/help_pages/tests/test_credential_expiry.py index 15ce86ab4..ab32a945a 100644 --- a/trustpoint/help_pages/tests/test_credential_expiry.py +++ b/trustpoint/help_pages/tests/test_credential_expiry.py @@ -1,3 +1,6 @@ +# 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 256faf642dc504f78de6a5909859ab3ccaf811a4 Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 11:17:03 +0200 Subject: [PATCH 6/7] Fix failing behave tests --- trustpoint/features/steps/common_steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c98e80079218b308fa20052e5acaabda8d02ac7b Mon Sep 17 00:00:00 2001 From: florianhandke Date: Fri, 2 Oct 2026 11:39:28 +0200 Subject: [PATCH 7/7] fixed failing test --- trustpoint/help_pages/tests/test_devices_help_views.py | 3 +++ 1 file changed, 3 insertions(+) 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'