Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "External TLS CSR focused tests",
"type": "shell",
"command": "uv run pytest trustpoint/pki/tests/test_external_csr.py trustpoint/request/tests/test_operation_processors_csr.py trustpoint/pki/tests/test_util_cert_req_converter.py -q -o md_report=false",
"isBackground": false,
"problemMatcher": []
},
{
"label": "External TLS wizard tests",
"type": "shell",
"command": "uv run pytest trustpoint/management/tests/test_views/test_tls_external_csr.py -q -o md_report=false",
"isBackground": false,
"problemMatcher": []
},
{
"label": "External TLS wizard first failure",
"type": "shell",
"command": "uv run pytest trustpoint/management/tests/test_views/test_tls_external_csr.py -x -q -o md_report=false",
"isBackground": false,
"problemMatcher": []
}
]
}
12 changes: 12 additions & 0 deletions trustpoint/crypto/adapters/software/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from crypto.domain.errors import (
KeyNotFoundError,
MechanismUnsupportedError,
ProviderConfigurationError,
ProviderUnavailableError,
UnsupportedKeySpecError,
)
Expand Down Expand Up @@ -110,6 +111,17 @@ def generate_managed_key(self, *, alias: str, key_spec: KeySpec, policy: KeyPoli
provider_label=alias,
)

def export_private_key_pkcs8(self, key: SoftwareManagedKeyBinding) -> bytes:
"""Export unencrypted PKCS#8 PEM after the application checks the key policy."""
if not self._profile.allow_exportable_private_keys:
msg = 'The software provider does not allow private-key export.'
raise ProviderConfigurationError(msg)
return self._load_private_key(key).private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)

def verify_managed_key(self, key: SoftwareManagedKeyBinding) -> SoftwareManagedKeyVerification:
"""Verify that a software-managed binding still decrypts to the expected key."""
try:
Expand Down
31 changes: 30 additions & 1 deletion trustpoint/crypto/application/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from crypto.adapters.pkcs11.bindings import Pkcs11ManagedKeyBinding
from crypto.adapters.protected_import.bindings import ProtectedImportManagedKeyBinding
from crypto.adapters.rest.bindings import RestManagedKeyBinding
from crypto.adapters.software.backend import SoftwareBackend
from crypto.adapters.software.bindings import SoftwareManagedKeyBinding
from crypto.application.audit import (
audit_crypto_backend_operation,
Expand All @@ -32,7 +33,13 @@
encrypt_imported_private_key,
imported_key_algorithm,
)
from crypto.domain.errors import CryptoError, KeyNotFoundError, ProviderConfigurationError, UnsupportedKeySpecError
from crypto.domain.errors import (
CryptoError,
KeyNotFoundError,
ProviderConfigurationError,
ProviderOperationNotImplementedError,
UnsupportedKeySpecError,
)
from crypto.domain.refs import ManagedKeyRef, ManagedKeyVerification, ManagedKeyVerificationStatus
from crypto.models import BackendKind, CryptoManagedKeyModel, CryptoProviderProfileModel
from crypto.repositories import CryptoManagedKeyRepository, CryptoProviderProfileRepository, ManagedKeyBinding
Expand Down Expand Up @@ -348,6 +355,28 @@ def get_public_key(self, key: ManagedKeyRef) -> SupportedPublicKey:
)
return public_key

def export_private_key_pkcs8(self, key: ManagedKeyRef) -> bytes:
"""Export an explicitly extractable software key as unencrypted PKCS#8 PEM."""
managed_key = self._load_managed_key(key)
if managed_key.policy_snapshot.get('extractable') is not True:
msg = 'The managed private key is not extractable.'
raise ProviderConfigurationError(msg)
if managed_key.provider_profile.backend_kind != BackendKind.SOFTWARE:
msg = 'Managed private-key export is only supported for software backend keys, not PKCS#11 or remote keys.'
raise ProviderOperationNotImplementedError(msg)
binding = self._managed_key_repository.build_backend_binding(managed_key)
if not isinstance(binding, SoftwareManagedKeyBinding):
msg = 'Managed private-key export requires an encrypted software backend binding.'
raise ProviderOperationNotImplementedError(msg)
adapter = self._build_adapter(managed_key.provider_profile)
try:
if not isinstance(adapter, SoftwareBackend):
msg = 'The configured software adapter does not support managed private-key export.'
raise ProviderOperationNotImplementedError(msg)
return adapter.export_private_key_pkcs8(binding)
finally:
adapter.close()

def sign(self, *, key: ManagedKeyRef, data: bytes, request: SignRequest) -> bytes:
"""Sign bytes with a managed key."""
managed_key = self._load_managed_key(key)
Expand Down
140 changes: 138 additions & 2 deletions trustpoint/crypto/tests/test_application_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
from unittest.mock import patch

import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

from appsecrets.models import AppSecretBackendKind, AppSecretBackendModel
from crypto.adapters.pkcs11.bindings import (
Pkcs11ManagedKeyBinding,
Pkcs11ManagedKeyVerification,
)
from crypto.adapters.software.backend import SoftwareBackend
from crypto.application.service import TrustpointCryptoBackend
from crypto.domain.algorithms import KeyAlgorithm
from crypto.domain.errors import ProviderConfigurationError
from crypto.domain.errors import AuthenticationError, ProviderConfigurationError, ProviderOperationNotImplementedError
from crypto.domain.policies import KeyPolicy, SigningExecutionMode
from crypto.domain.refs import ManagedKeyVerificationStatus
from crypto.domain.specs import RsaKeySpec, SignRequest
Expand All @@ -36,6 +37,141 @@
SoftwareKeyEncryptionSource,
)
from management.models import AuditLog, LoggingConfig, SecurityConfig
from pki.models.credential import CredentialModel


@pytest.fixture
def export_profile(monkeypatch: pytest.MonkeyPatch) -> CryptoProviderProfileModel:
"""Configure the real software backend with protected encryption material."""
monkeypatch.setenv('TRUSTPOINT_TEST_TLS_KEY_SECRET', 'test-only-encryption-material')
profile = CryptoProviderProfileModel.objects.create(
name='tls-export', backend_kind=BackendKind.SOFTWARE, active=True,
)
CryptoProviderSoftwareConfigModel.objects.create(
profile=profile,
encryption_source=SoftwareKeyEncryptionSource.ENV,
encryption_source_ref='TRUSTPOINT_TEST_TLS_KEY_SECRET',
allow_exportable_private_keys=True,
)
return profile


@pytest.mark.django_db
def test_export_private_key_pkcs8_matches_generated_key(export_profile: CryptoProviderProfileModel) -> None:
"""Export decrypts the persisted key rather than generating a replacement."""
backend = TrustpointCryptoBackend()
key = backend.generate_managed_key(
alias='tls/server', key_spec=RsaKeySpec(key_size=2048),
policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH),
)
exported = backend.export_private_key_pkcs8(key)
private_key = serialization.load_pem_private_key(exported, password=None)
assert exported.startswith(b'-----BEGIN PRIVATE KEY-----')
assert private_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo,
) == backend.get_public_key(key).public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo,
)
managed_key = CryptoManagedKeyModel.objects.get(pk=key.id)
assert managed_key.provider_profile_id == export_profile.pk
assert managed_key.policy_snapshot['extractable'] is True
assert bytes(managed_key.software_binding.encrypted_private_key_pkcs8_der) != exported
assert CryptoManagedKeyModel.objects.count() == 1


@pytest.mark.django_db
@pytest.mark.usefixtures('export_profile')
def test_export_private_key_pkcs8_rejects_nonextractable_key() -> None:
"""The default CA policy stays non-exportable even on an export-enabled provider."""
backend = TrustpointCryptoBackend()
key = backend.generate_managed_key(
alias='ca/root', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy.managed_signing_key(),
)
with pytest.raises(ProviderConfigurationError, match='not extractable'):
backend.export_private_key_pkcs8(key)


@pytest.mark.django_db
def test_export_private_key_pkcs8_rejects_provider_policy(export_profile: CryptoProviderProfileModel) -> None:
"""A key opt-in cannot override the provider's export restriction."""
backend = TrustpointCryptoBackend()
key = backend.generate_managed_key(
alias='tls/server', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True),
)
config = export_profile.software_config
config.allow_exportable_private_keys = False
config.save(update_fields=['allow_exportable_private_keys'])
with pytest.raises(ProviderConfigurationError, match='does not allow private-key export'):
backend.export_private_key_pkcs8(key)


@pytest.mark.django_db
@pytest.mark.parametrize('backend_kind', [BackendKind.PKCS11, BackendKind.REST])
def test_export_private_key_pkcs8_rejects_unsupported_backend(backend_kind: str) -> None:
"""Non-software providers have no safe deployment export contract."""
profile = CryptoProviderProfileModel.objects.create(name='unsupported-export', backend_kind=backend_kind)
managed_key = CryptoManagedKeyModel.objects.create(
alias='tls/unsupported', provider_profile=profile, algorithm='rsa',
public_key_fingerprint_sha256='a' * 64, policy_snapshot={'extractable': True},
)
with pytest.raises(ProviderOperationNotImplementedError, match='only supported for software'):
TrustpointCryptoBackend().export_private_key_pkcs8(managed_key.to_managed_key_ref())


@pytest.mark.django_db
@pytest.mark.usefixtures('export_profile')
def test_managed_tls_credential_serializer_exports_without_regenerating() -> None:
"""The activation serializer exports the persisted TLS key, but its wrapper cannot."""
backend = TrustpointCryptoBackend()
with patch.object(
SoftwareBackend, 'generate_managed_key', autospec=True, side_effect=SoftwareBackend.generate_managed_key,
) as generate:
key = backend.generate_managed_key(
alias='tls/credential', key_spec=RsaKeySpec(key_size=2048),
policy=KeyPolicy(extractable=True, signing_execution_mode=SigningExecutionMode.ALLOW_APPLICATION_HASH),
)
credential = CredentialModel.objects.create(
credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER,
managed_private_key_id=key.id,
)
credential.refresh_from_db()
assert credential.private_key == ''
assert credential.get_private_key_serializer().as_pkcs8_pem() == backend.export_private_key_pkcs8(key)
assert generate.call_count == 1
with pytest.raises(NotImplementedError, match='cannot be exported'):
credential.get_private_key().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(),
)


@pytest.mark.django_db
@pytest.mark.parametrize('credential_type', [
CredentialModel.CredentialTypeChoice.ROOT_CA, CredentialModel.CredentialTypeChoice.ISSUING_CA,
])
@pytest.mark.usefixtures('export_profile')
def test_ca_credential_serializer_does_not_use_tls_export(credential_type: int) -> None:
"""Even an extractable key does not grant CA credentials the TLS serializer path."""
key = TrustpointCryptoBackend().generate_managed_key(
alias='ca/credential', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True),
)
credential = CredentialModel.objects.create(credential_type=credential_type, managed_private_key_id=key.id)
with patch.object(TrustpointCryptoBackend, 'export_private_key_pkcs8') as export:
with pytest.raises((RuntimeError, NotImplementedError), match='cannot be exported'):
credential.get_private_key_serializer().as_pkcs8_pem()
export.assert_not_called()


@pytest.mark.django_db
@pytest.mark.usefixtures('export_profile')
def test_export_private_key_pkcs8_requires_encryption_secret(monkeypatch: pytest.MonkeyPatch) -> None:
"""Export still uses the configured secret resolver to unseal encrypted material."""
backend = TrustpointCryptoBackend()
key = backend.generate_managed_key(
alias='tls/protected', key_spec=RsaKeySpec(key_size=2048), policy=KeyPolicy(extractable=True),
)
monkeypatch.delenv('TRUSTPOINT_TEST_TLS_KEY_SECRET')
with pytest.raises(AuthenticationError, match='is missing'):
backend.export_private_key_pkcs8(key)


@dataclass
Expand Down
Loading
Loading