vortexpy 6.3.1__tar.gz → 6.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vortexpy-6.3.1 → vortexpy-6.4.0}/PKG-INFO +1 -1
- {vortexpy-6.3.1 → vortexpy-6.4.0}/pyproject.toml +1 -1
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsServerSslOptions.py +76 -43
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexFactory.py +16 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerTcp.py +82 -5
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerTcp_test.py +56 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerWebsocket.py +75 -4
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerWebsocket_test.py +75 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/__init__.py +1 -1
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableProxyHandler.py +13 -5
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/twisted_runtime_metrics.py +105 -60
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/twisted_runtime_metrics_test.py +34 -9
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/PKG-INFO +1 -1
- {vortexpy-6.3.1 → vortexpy-6.4.0}/README.md +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/TwistedDeferredTimeout.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_ping_client.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_ping_server.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/setup_example_logging.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/example/store_and_load_json.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/setup.cfg +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DeferUtil.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DeferUtil_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Jsonable.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Jsonable_tuple_dict_coercion_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Payload.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadIO.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadPriority.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponse.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/SerialiseUtil.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/SerialiseUtil_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TestTuple.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Tuple.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleAction.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleBson.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleBson_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleDocumentConverter.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleRestful.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleRestful_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleSelector.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientTcp.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientWebsocketFactory.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexFactory_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPermission.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServer.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerConnection.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerConnection_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServer_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexSession.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexSession_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUserDetailsABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUtil.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUtil_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexWritePushProducer_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLiveLockManager_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessorProxy.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionPushOffline.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionPushOffline_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataActionClient_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableCache.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableCache_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableHandler.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableHandler_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObserverClient.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataOfflineObserverService.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataOfflineObserverService_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/mypy_plugin.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/payload_many_tuples_benchmark_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/py.typed +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/restful_http_client_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/restful_resource_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleActionStorageSqlite.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageNameService.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageSecureService.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageSecureService_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageService.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageServiceABC.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqliteService.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqliteService_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_test_fixtures.py +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/SOURCES.txt +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/requires.txt +0 -0
- {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vortexpy"
|
|
7
|
-
version = "6.
|
|
7
|
+
version = "6.4.0"
|
|
8
8
|
license = {text = "MIT"}
|
|
9
9
|
authors = [{name = "Flying Ion Pty Ltd", email = "contact@flyingion.com"}]
|
|
10
10
|
description = "Flying Ion's observable, routable, data serialisation and transport code."
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
-
from
|
|
14
|
+
from collections.abc import Callable
|
|
15
15
|
|
|
16
16
|
from OpenSSL import SSL, crypto
|
|
17
17
|
from twisted.internet._sslverify import Certificate, KeyPair, TLSVersion
|
|
@@ -31,9 +31,20 @@ class _MtlsServerCertificateOptions(CertificateOptions):
|
|
|
31
31
|
self,
|
|
32
32
|
*,
|
|
33
33
|
authorized_client_registry: MtlsAuthorizedClientRegistry,
|
|
34
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
34
35
|
**kwargs,
|
|
35
36
|
) -> None:
|
|
37
|
+
"""Constructor
|
|
38
|
+
|
|
39
|
+
:param authorized_client_registry: Mutable authorized client public
|
|
40
|
+
keys consulted by the verify callback.
|
|
41
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook receiving
|
|
42
|
+
the peer leaf certificate PEM; consulted only when OpenSSL has
|
|
43
|
+
already validated the chain (``preverify_ok == 1``) and the
|
|
44
|
+
registry does not authorize the peer public key.
|
|
45
|
+
"""
|
|
36
46
|
self._authorizedClientRegistry = authorized_client_registry
|
|
47
|
+
self._peerCertAcceptanceHook = peer_cert_acceptance_hook
|
|
37
48
|
super().__init__(**kwargs)
|
|
38
49
|
|
|
39
50
|
def _makeContext(self):
|
|
@@ -57,47 +68,7 @@ class _MtlsServerCertificateOptions(CertificateOptions):
|
|
|
57
68
|
verify_flags |= SSL.VERIFY_CLIENT_ONCE
|
|
58
69
|
self.trustRoot._addCACertsToContext(ctx)
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def verifyCallback(
|
|
63
|
-
conn: SSL.Connection,
|
|
64
|
-
cert: crypto.X509,
|
|
65
|
-
errno: int,
|
|
66
|
-
depth: int,
|
|
67
|
-
preverify_ok: int,
|
|
68
|
-
) -> int:
|
|
69
|
-
fail = 0
|
|
70
|
-
|
|
71
|
-
if depth != 0:
|
|
72
|
-
return preverify_ok
|
|
73
|
-
|
|
74
|
-
if preverify_ok == fail:
|
|
75
|
-
return preverify_ok
|
|
76
|
-
|
|
77
|
-
session_peer_public_key_bytes = crypto.dump_publickey(
|
|
78
|
-
crypto.FILETYPE_ASN1, cert.get_pubkey()
|
|
79
|
-
)
|
|
80
|
-
if not registry.isPublicKeyAuthorized(session_peer_public_key_bytes):
|
|
81
|
-
subject = cert.get_subject()
|
|
82
|
-
logger.error(
|
|
83
|
-
"mTLS peer verify failed: certificate not authorized subject=%s",
|
|
84
|
-
subject,
|
|
85
|
-
)
|
|
86
|
-
return fail
|
|
87
|
-
|
|
88
|
-
identity = registry.getIdentityForPublicKey(
|
|
89
|
-
session_peer_public_key_bytes
|
|
90
|
-
)
|
|
91
|
-
if identity is not None:
|
|
92
|
-
logger.info(
|
|
93
|
-
"mTLS peer verified instanceId=%s owner=%s fingerprint=%s",
|
|
94
|
-
identity.instanceId or "(unknown)",
|
|
95
|
-
identity.ownerUsername or "(unknown)",
|
|
96
|
-
identity.fingerprint,
|
|
97
|
-
)
|
|
98
|
-
return preverify_ok
|
|
99
|
-
|
|
100
|
-
ctx.set_verify(verify_flags, verifyCallback)
|
|
71
|
+
ctx.set_verify(verify_flags, self._verifyPeer)
|
|
101
72
|
if self.verifyDepth is not None:
|
|
102
73
|
ctx.set_verify_depth(self.verifyDepth)
|
|
103
74
|
|
|
@@ -124,14 +95,70 @@ class _MtlsServerCertificateOptions(CertificateOptions):
|
|
|
124
95
|
|
|
125
96
|
return ctx
|
|
126
97
|
|
|
98
|
+
def _verifyPeer(
|
|
99
|
+
self,
|
|
100
|
+
conn: SSL.Connection,
|
|
101
|
+
cert: crypto.X509,
|
|
102
|
+
errno: int,
|
|
103
|
+
depth: int,
|
|
104
|
+
preverify_ok: int,
|
|
105
|
+
) -> int:
|
|
106
|
+
"""OpenSSL verify callback for the registry-backed mTLS allowlist.
|
|
107
|
+
|
|
108
|
+
OpenSSL validates the chain (signatures, validity, path length)
|
|
109
|
+
before this callback reports the leaf at depth 0. A
|
|
110
|
+
``preverify_ok == 0`` leaf is returned unchanged so expiry and
|
|
111
|
+
path-length violations are never bypassed by the acceptance hook.
|
|
112
|
+
"""
|
|
113
|
+
fail = 0
|
|
114
|
+
|
|
115
|
+
if depth != 0:
|
|
116
|
+
return preverify_ok
|
|
117
|
+
|
|
118
|
+
if preverify_ok == fail:
|
|
119
|
+
return preverify_ok
|
|
120
|
+
|
|
121
|
+
registry = self._authorizedClientRegistry
|
|
122
|
+
session_peer_public_key_bytes = crypto.dump_publickey(
|
|
123
|
+
crypto.FILETYPE_ASN1, cert.get_pubkey()
|
|
124
|
+
)
|
|
125
|
+
if not registry.isPublicKeyAuthorized(session_peer_public_key_bytes):
|
|
126
|
+
acceptanceHook = self._peerCertAcceptanceHook
|
|
127
|
+
if acceptanceHook is not None and acceptanceHook(
|
|
128
|
+
crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("ascii")
|
|
129
|
+
):
|
|
130
|
+
logger.info(
|
|
131
|
+
"mTLS peer verified via peer certificate"
|
|
132
|
+
" acceptance hook subject=%s",
|
|
133
|
+
cert.get_subject(),
|
|
134
|
+
)
|
|
135
|
+
return preverify_ok
|
|
136
|
+
subject = cert.get_subject()
|
|
137
|
+
logger.error(
|
|
138
|
+
"mTLS peer verify failed: certificate not authorized subject=%s",
|
|
139
|
+
subject,
|
|
140
|
+
)
|
|
141
|
+
return fail
|
|
142
|
+
|
|
143
|
+
identity = registry.getIdentityForPublicKey(session_peer_public_key_bytes)
|
|
144
|
+
if identity is not None:
|
|
145
|
+
logger.info(
|
|
146
|
+
"mTLS peer verified instanceId=%s owner=%s fingerprint=%s",
|
|
147
|
+
identity.instanceId or "(unknown)",
|
|
148
|
+
identity.ownerUsername or "(unknown)",
|
|
149
|
+
identity.fingerprint,
|
|
150
|
+
)
|
|
151
|
+
return preverify_ok
|
|
152
|
+
|
|
127
153
|
|
|
128
154
|
def buildMtlsServerCertificateOptions(
|
|
129
155
|
private_key_with_full_chain: PrivateKeyWithFullChain,
|
|
130
156
|
trust_root,
|
|
131
157
|
authorized_client_registry: MtlsAuthorizedClientRegistry,
|
|
132
158
|
*,
|
|
133
|
-
acceptable_protocols:
|
|
159
|
+
acceptable_protocols: list[bytes] | None = None,
|
|
134
160
|
dh_parameters=None,
|
|
161
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
135
162
|
) -> CertificateOptions:
|
|
136
163
|
"""Build server TLS options with dynamic authorized-client verification.
|
|
137
164
|
|
|
@@ -142,6 +169,11 @@ def buildMtlsServerCertificateOptions(
|
|
|
142
169
|
:param private_key_with_full_chain: Server key + certificate chain PEM bank.
|
|
143
170
|
:param trust_root: Twisted trust root for client certificate chain validation.
|
|
144
171
|
:param authorized_client_registry: Mutable authorized client public keys.
|
|
172
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook receiving the
|
|
173
|
+
peer leaf certificate PEM, consulted only when OpenSSL has validated
|
|
174
|
+
the chain and the registry does not authorize the peer public key;
|
|
175
|
+
it adds constraints OpenSSL cannot know (pinned-issuer membership,
|
|
176
|
+
certificate-name policy).
|
|
145
177
|
:return: Twisted ``CertificateOptions`` for ``reactor.listenSSL``.
|
|
146
178
|
"""
|
|
147
179
|
if acceptable_protocols is None:
|
|
@@ -165,6 +197,7 @@ def buildMtlsServerCertificateOptions(
|
|
|
165
197
|
|
|
166
198
|
return _MtlsServerCertificateOptions(
|
|
167
199
|
authorized_client_registry=authorized_client_registry,
|
|
200
|
+
peer_cert_acceptance_hook=peer_cert_acceptance_hook,
|
|
168
201
|
privateKey=twisted_loaded_chain.privateKey,
|
|
169
202
|
certificate=twisted_loaded_chain.certificate,
|
|
170
203
|
extraCertChain=twisted_loaded_chain.intermediateCAs,
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
12
|
from collections import defaultdict, namedtuple
|
|
13
|
+
from collections.abc import Callable
|
|
13
14
|
from datetime import datetime
|
|
14
15
|
|
|
15
16
|
import pytz
|
|
@@ -581,6 +582,7 @@ class VortexFactory:
|
|
|
581
582
|
cls,
|
|
582
583
|
name: str,
|
|
583
584
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
585
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
584
586
|
) -> VortexWebSocketUpgradeResource:
|
|
585
587
|
"""Create Http Websocket Resource
|
|
586
588
|
|
|
@@ -588,6 +590,9 @@ class VortexFactory:
|
|
|
588
590
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
589
591
|
certificate registry forwarded to the upgrade resource;
|
|
590
592
|
``None`` when the listener is not mTLS.
|
|
593
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
594
|
+
forwarded to the upgrade resource, consulted only when the
|
|
595
|
+
registry does not authorize the peer public key.
|
|
591
596
|
:return: The configured :class:`VortexWebSocketUpgradeResource`.
|
|
592
597
|
"""
|
|
593
598
|
vortexServer = VortexServer(name, requiresBase64Encoding=False)
|
|
@@ -599,6 +604,7 @@ class VortexFactory:
|
|
|
599
604
|
return VortexWebSocketUpgradeResource(
|
|
600
605
|
websocketFactory,
|
|
601
606
|
mtlsAuthorizedClientRegistry=mtlsAuthorizedClientRegistry,
|
|
607
|
+
peer_cert_acceptance_hook=peer_cert_acceptance_hook,
|
|
602
608
|
)
|
|
603
609
|
|
|
604
610
|
@classmethod
|
|
@@ -607,6 +613,7 @@ class VortexFactory:
|
|
|
607
613
|
name: str,
|
|
608
614
|
rootResource,
|
|
609
615
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
616
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
610
617
|
) -> None:
|
|
611
618
|
"""Create Server
|
|
612
619
|
|
|
@@ -620,6 +627,9 @@ class VortexFactory:
|
|
|
620
627
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
621
628
|
certificate registry forwarded to the upgrade resource;
|
|
622
629
|
``None`` when the listener is not mTLS.
|
|
630
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
631
|
+
forwarded to the upgrade resource, consulted only when the
|
|
632
|
+
registry does not authorize the peer public key.
|
|
623
633
|
:return: None
|
|
624
634
|
"""
|
|
625
635
|
|
|
@@ -631,6 +641,7 @@ class VortexFactory:
|
|
|
631
641
|
websocketResource = VortexWebSocketUpgradeResource(
|
|
632
642
|
websocketFactory,
|
|
633
643
|
mtlsAuthorizedClientRegistry=mtlsAuthorizedClientRegistry,
|
|
644
|
+
peer_cert_acceptance_hook=peer_cert_acceptance_hook,
|
|
634
645
|
)
|
|
635
646
|
rootResource.putChild(b"vortexws", websocketResource)
|
|
636
647
|
|
|
@@ -640,6 +651,7 @@ class VortexFactory:
|
|
|
640
651
|
name: str,
|
|
641
652
|
port: int,
|
|
642
653
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
654
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
643
655
|
) -> None:
|
|
644
656
|
"""Create Server
|
|
645
657
|
|
|
@@ -653,6 +665,9 @@ class VortexFactory:
|
|
|
653
665
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
654
666
|
certificate registry forwarded to the TCP server factory;
|
|
655
667
|
``None`` when the listener is not mTLS.
|
|
668
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
669
|
+
forwarded to the TCP server factory, consulted only when the
|
|
670
|
+
registry does not authorize the peer public key.
|
|
656
671
|
:return: None
|
|
657
672
|
"""
|
|
658
673
|
|
|
@@ -661,6 +676,7 @@ class VortexFactory:
|
|
|
661
676
|
vortexTcpServerFactory = VortexTcpServerFactory(
|
|
662
677
|
vortexServer,
|
|
663
678
|
mtlsAuthorizedClientRegistry=mtlsAuthorizedClientRegistry,
|
|
679
|
+
peer_cert_acceptance_hook=peer_cert_acceptance_hook,
|
|
664
680
|
)
|
|
665
681
|
port = reactor.listenTCP(port, vortexTcpServerFactory)
|
|
666
682
|
cls.__listeningPorts.append(port)
|
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import hashlib
|
|
11
12
|
import logging
|
|
13
|
+
from collections.abc import Callable
|
|
12
14
|
|
|
13
15
|
from OpenSSL import crypto
|
|
14
16
|
from twisted.internet.defer import succeed
|
|
15
17
|
from twisted.internet.protocol import Factory, connectionDone
|
|
16
18
|
|
|
17
|
-
from vortex.MtlsAuthorizedClientRegistry import
|
|
19
|
+
from vortex.MtlsAuthorizedClientRegistry import (
|
|
20
|
+
AuthorizedClientIdentity,
|
|
21
|
+
MtlsAuthorizedClientRegistry,
|
|
22
|
+
)
|
|
18
23
|
from vortex.VortexPayloadProtocol import VortexPayloadProtocol
|
|
19
24
|
from vortex.VortexServerConnection import VortexServerConnection
|
|
20
25
|
from vortex.VortexSession import (
|
|
@@ -36,6 +41,7 @@ class VortexTcpServerProtocol(VortexPayloadProtocol):
|
|
|
36
41
|
vortex: VortexServer,
|
|
37
42
|
addr,
|
|
38
43
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
44
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
39
45
|
) -> None:
|
|
40
46
|
"""Constructor
|
|
41
47
|
|
|
@@ -44,11 +50,17 @@ class VortexTcpServerProtocol(VortexPayloadProtocol):
|
|
|
44
50
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
45
51
|
certificate registry for this listener; ``None`` when the
|
|
46
52
|
listener is not mTLS.
|
|
53
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
54
|
+
receiving the peer leaf certificate PEM, consulted only when
|
|
55
|
+
the registry does not authorize the peer public key; it adds
|
|
56
|
+
constraints OpenSSL cannot know (pinned-issuer membership,
|
|
57
|
+
certificate-name policy).
|
|
47
58
|
"""
|
|
48
59
|
VortexPayloadProtocol.__init__(self, logger)
|
|
49
60
|
self._vortex = vortex
|
|
50
61
|
self._addr = addr
|
|
51
62
|
self._mtlsAuthorizedClientRegistry = mtlsAuthorizedClientRegistry
|
|
63
|
+
self._peerCertAcceptanceHook = peer_cert_acceptance_hook
|
|
52
64
|
|
|
53
65
|
self._conn = None
|
|
54
66
|
|
|
@@ -94,10 +106,14 @@ class VortexTcpServerProtocol(VortexPayloadProtocol):
|
|
|
94
106
|
When an authorized-client registry is present and the TLS
|
|
95
107
|
transport carries a peer certificate, the peer public key is
|
|
96
108
|
resolved to its :class:`AuthorizedClientIdentity` and stored on
|
|
97
|
-
the session before :class:`PayloadIO` processes payloads.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
the session before :class:`PayloadIO` processes payloads. When
|
|
110
|
+
the registry does not hold the peer key, the peer certificate
|
|
111
|
+
acceptance hook decides identity resolution — an accepted peer
|
|
112
|
+
surfaces an identity derived from its certificate. The session
|
|
113
|
+
attribute stays ``None`` when no registry is passed, the
|
|
114
|
+
listener is not mTLS, or neither the registry nor the acceptance
|
|
115
|
+
hook authorizes the peer
|
|
116
|
+
(``_MtlsServerCertificateOptions._verifyPeer`` already rejects
|
|
101
117
|
unauthorized keys during the TLS handshake).
|
|
102
118
|
"""
|
|
103
119
|
if self._mtlsAuthorizedClientRegistry is None:
|
|
@@ -116,6 +132,10 @@ class VortexTcpServerProtocol(VortexPayloadProtocol):
|
|
|
116
132
|
identity = self._mtlsAuthorizedClientRegistry.getIdentityForPublicKey(
|
|
117
133
|
publicKeyAsn1
|
|
118
134
|
)
|
|
135
|
+
if identity is None:
|
|
136
|
+
identity = self._resolveIdentityFromAcceptanceHook(
|
|
137
|
+
peerCertificate, publicKeyAsn1
|
|
138
|
+
)
|
|
119
139
|
if identity is None:
|
|
120
140
|
logger.warning(
|
|
121
141
|
"mTLS vortex TCP peer public key is not authorized;"
|
|
@@ -133,6 +153,57 @@ class VortexTcpServerProtocol(VortexPayloadProtocol):
|
|
|
133
153
|
)
|
|
134
154
|
vortexSession.mtlsClientIdentity = identity
|
|
135
155
|
|
|
156
|
+
def _resolveIdentityFromAcceptanceHook(
|
|
157
|
+
self,
|
|
158
|
+
peerCertificate: crypto.X509,
|
|
159
|
+
publicKeyAsn1: bytes,
|
|
160
|
+
) -> AuthorizedClientIdentity | None:
|
|
161
|
+
"""Resolve an identity for a peer the acceptance hook accepts.
|
|
162
|
+
|
|
163
|
+
The registry authorizes hub-issued certificates by public key;
|
|
164
|
+
peers the TLS acceptance hook accepted (for example
|
|
165
|
+
broker-issued leaves chaining to a pinned intermediate) surface
|
|
166
|
+
an identity derived from their certificate — the common name
|
|
167
|
+
identifies the peer and the fingerprint supports audit logs.
|
|
168
|
+
|
|
169
|
+
:param peerCertificate: The peer leaf certificate from the TLS
|
|
170
|
+
transport
|
|
171
|
+
:param publicKeyAsn1: The peer public key, DER encoded
|
|
172
|
+
:return: The derived identity, or ``None`` when no hook is
|
|
173
|
+
wired or the hook rejects the peer
|
|
174
|
+
"""
|
|
175
|
+
acceptanceHook = self._peerCertAcceptanceHook
|
|
176
|
+
if acceptanceHook is None:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
certificatePem = crypto.dump_certificate(
|
|
180
|
+
crypto.FILETYPE_PEM, peerCertificate
|
|
181
|
+
).decode("ascii")
|
|
182
|
+
if not acceptanceHook(certificatePem):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
fingerprint = hashlib.sha256(
|
|
186
|
+
crypto.dump_certificate(crypto.FILETYPE_ASN1, peerCertificate)
|
|
187
|
+
).hexdigest()[:16]
|
|
188
|
+
subject = peerCertificate.get_subject()
|
|
189
|
+
commonName = (
|
|
190
|
+
subject.CN.decode("utf-8")
|
|
191
|
+
if isinstance(subject.CN, bytes)
|
|
192
|
+
else str(subject.CN or "")
|
|
193
|
+
)
|
|
194
|
+
logger.info(
|
|
195
|
+
"mTLS peer accepted via certificate acceptance hook cn=%s fingerprint=%s",
|
|
196
|
+
commonName,
|
|
197
|
+
fingerprint,
|
|
198
|
+
)
|
|
199
|
+
return AuthorizedClientIdentity(
|
|
200
|
+
fingerprint=fingerprint,
|
|
201
|
+
publicKeyAsn1=publicKeyAsn1,
|
|
202
|
+
instanceId=commonName,
|
|
203
|
+
ownerUsername="",
|
|
204
|
+
commonName=commonName,
|
|
205
|
+
)
|
|
206
|
+
|
|
136
207
|
def _deliverPayload(self, payloadEnvelope):
|
|
137
208
|
vs = (
|
|
138
209
|
self._httpSession.getComponent(IVortexSession)
|
|
@@ -186,6 +257,7 @@ class VortexTcpServerFactory(Factory):
|
|
|
186
257
|
self,
|
|
187
258
|
vortexServer: VortexServer,
|
|
188
259
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
260
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
189
261
|
) -> None:
|
|
190
262
|
"""Constructor
|
|
191
263
|
|
|
@@ -194,9 +266,13 @@ class VortexTcpServerFactory(Factory):
|
|
|
194
266
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
195
267
|
certificate registry for this listener; ``None`` when the
|
|
196
268
|
listener is not mTLS.
|
|
269
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
270
|
+
forwarded to built protocols, consulted only when the
|
|
271
|
+
registry does not authorize the peer public key.
|
|
197
272
|
"""
|
|
198
273
|
self._vortexServer = vortexServer
|
|
199
274
|
self._mtlsAuthorizedClientRegistry = mtlsAuthorizedClientRegistry
|
|
275
|
+
self._peerCertAcceptanceHook = peer_cert_acceptance_hook
|
|
200
276
|
|
|
201
277
|
def buildProtocol(self, addr):
|
|
202
278
|
from vortex.VortexFactory import VortexFactory
|
|
@@ -208,6 +284,7 @@ class VortexTcpServerFactory(Factory):
|
|
|
208
284
|
self._vortexServer,
|
|
209
285
|
addr,
|
|
210
286
|
mtlsAuthorizedClientRegistry=self._mtlsAuthorizedClientRegistry,
|
|
287
|
+
peer_cert_acceptance_hook=self._peerCertAcceptanceHook,
|
|
211
288
|
)
|
|
212
289
|
p.factory = self
|
|
213
290
|
return p
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
from collections.abc import Callable
|
|
13
14
|
from unittest.mock import MagicMock, patch
|
|
14
15
|
|
|
15
16
|
from OpenSSL import crypto
|
|
@@ -36,6 +37,7 @@ def _makeSelfSignedCert(commonName: bytes = b"agent-instance-1") -> crypto.X509:
|
|
|
36
37
|
def _makeProtocol(
|
|
37
38
|
registry: MtlsAuthorizedClientRegistry | None,
|
|
38
39
|
transport,
|
|
40
|
+
peerCertAcceptanceHook: Callable[[str], bool] | None = None,
|
|
39
41
|
) -> VortexTcpServerProtocol:
|
|
40
42
|
vortex = MagicMock()
|
|
41
43
|
vortex.uuid.return_value = "server-uuid"
|
|
@@ -44,6 +46,7 @@ def _makeProtocol(
|
|
|
44
46
|
vortex,
|
|
45
47
|
MagicMock(host="10.0.0.2"),
|
|
46
48
|
mtlsAuthorizedClientRegistry=registry,
|
|
49
|
+
peer_cert_acceptance_hook=peerCertAcceptanceHook,
|
|
47
50
|
)
|
|
48
51
|
protocol.transport = transport
|
|
49
52
|
return protocol
|
|
@@ -59,6 +62,20 @@ def test_factory_hands_registry_to_built_protocols() -> None:
|
|
|
59
62
|
assert protocol._mtlsAuthorizedClientRegistry is registry
|
|
60
63
|
|
|
61
64
|
|
|
65
|
+
def test_factory_hands_acceptance_hook_to_built_protocols() -> None:
|
|
66
|
+
def acceptingHook(leafCertPem: str) -> bool:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
factory = VortexTcpServerFactory(
|
|
70
|
+
MagicMock(), peer_cert_acceptance_hook=acceptingHook
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with patch("vortex.VortexFactory.VortexFactory.canConnect", return_value=True):
|
|
74
|
+
protocol = factory.buildProtocol(MagicMock(host="10.0.0.2"))
|
|
75
|
+
|
|
76
|
+
assert protocol._peerCertAcceptanceHook is acceptingHook
|
|
77
|
+
|
|
78
|
+
|
|
62
79
|
def test_factory_defaults_registry_to_none() -> None:
|
|
63
80
|
factory = VortexTcpServerFactory(MagicMock())
|
|
64
81
|
|
|
@@ -119,3 +136,42 @@ def test_mtls_identity_none_for_unauthorized_peer_key() -> None:
|
|
|
119
136
|
|
|
120
137
|
session = protocol._httpSession.getComponent(IVortexSession)
|
|
121
138
|
assert session.mtlsClientIdentity is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_mtls_identity_resolved_for_hook_accepted_peer_cert() -> None:
|
|
142
|
+
registry = MtlsAuthorizedClientRegistry()
|
|
143
|
+
transport = MagicMock()
|
|
144
|
+
transport.getPeerCertificate.return_value = _makeSelfSignedCert(
|
|
145
|
+
b"user-mcp-operator@example.com"
|
|
146
|
+
)
|
|
147
|
+
protocol = _makeProtocol(registry, transport, lambda leafCertPem: True)
|
|
148
|
+
|
|
149
|
+
protocol._nameAndUuidReceived("remote-vortex", "remote-uuid")
|
|
150
|
+
|
|
151
|
+
session = protocol._httpSession.getComponent(IVortexSession)
|
|
152
|
+
assert session.mtlsClientIdentity is not None, (
|
|
153
|
+
"a hook-accepted peer must surface an identity on the session"
|
|
154
|
+
)
|
|
155
|
+
assert session.mtlsClientIdentity.commonName == "user-mcp-operator@example.com"
|
|
156
|
+
assert session.mtlsClientIdentity.instanceId == "user-mcp-operator@example.com", (
|
|
157
|
+
"the derived identity names the peer by its certificate common name"
|
|
158
|
+
)
|
|
159
|
+
assert len(session.mtlsClientIdentity.fingerprint) == 16, (
|
|
160
|
+
"the derived fingerprint mirrors the registry's sha256[:16] form"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_mtls_identity_none_when_hook_rejects() -> None:
|
|
165
|
+
registry = MtlsAuthorizedClientRegistry()
|
|
166
|
+
transport = MagicMock()
|
|
167
|
+
transport.getPeerCertificate.return_value = _makeSelfSignedCert(
|
|
168
|
+
b"user-mcp-operator@example.com"
|
|
169
|
+
)
|
|
170
|
+
protocol = _makeProtocol(registry, transport, lambda leafCertPem: False)
|
|
171
|
+
|
|
172
|
+
protocol._nameAndUuidReceived("remote-vortex", "remote-uuid")
|
|
173
|
+
|
|
174
|
+
session = protocol._httpSession.getComponent(IVortexSession)
|
|
175
|
+
assert session.mtlsClientIdentity is None, (
|
|
176
|
+
"a hook-rejected peer must carry no client identity"
|
|
177
|
+
)
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import hashlib
|
|
11
12
|
import json
|
|
12
13
|
import logging
|
|
13
14
|
from collections import deque
|
|
15
|
+
from collections.abc import Callable
|
|
14
16
|
from datetime import datetime, timezone
|
|
15
17
|
from random import random
|
|
16
18
|
from urllib.parse import parse_qs, urlparse
|
|
@@ -25,7 +27,10 @@ from twisted.web.server import NOT_DONE_YET
|
|
|
25
27
|
from txwebsocket.txws import WebSocketProtocol
|
|
26
28
|
|
|
27
29
|
from vortex.DeferUtil import nonConcurrentMethod
|
|
28
|
-
from vortex.MtlsAuthorizedClientRegistry import
|
|
30
|
+
from vortex.MtlsAuthorizedClientRegistry import (
|
|
31
|
+
AuthorizedClientIdentity,
|
|
32
|
+
MtlsAuthorizedClientRegistry,
|
|
33
|
+
)
|
|
29
34
|
from vortex.PayloadEnvelope import PayloadEnvelope
|
|
30
35
|
from vortex.VortexServerConnection import VortexServerConnection
|
|
31
36
|
from vortex.VortexSession import (
|
|
@@ -270,6 +275,7 @@ class VortexWebSocketUpgradeResource(resource.Resource):
|
|
|
270
275
|
self,
|
|
271
276
|
websocketFactory,
|
|
272
277
|
mtlsAuthorizedClientRegistry: MtlsAuthorizedClientRegistry | None = None,
|
|
278
|
+
peer_cert_acceptance_hook: Callable[[str], bool] | None = None,
|
|
273
279
|
) -> None:
|
|
274
280
|
"""Constructor
|
|
275
281
|
|
|
@@ -278,10 +284,16 @@ class VortexWebSocketUpgradeResource(resource.Resource):
|
|
|
278
284
|
:param mtlsAuthorizedClientRegistry: Authorized mTLS client
|
|
279
285
|
certificate registry for this listener; ``None`` when the
|
|
280
286
|
listener is not mTLS.
|
|
287
|
+
:param peer_cert_acceptance_hook: Optional acceptance hook
|
|
288
|
+
receiving the peer leaf certificate PEM, consulted only when
|
|
289
|
+
the registry does not authorize the peer public key; it adds
|
|
290
|
+
constraints OpenSSL cannot know (pinned-issuer membership,
|
|
291
|
+
certificate-name policy).
|
|
281
292
|
"""
|
|
282
293
|
resource.Resource.__init__(self)
|
|
283
294
|
self._websocketFactory = websocketFactory
|
|
284
295
|
self._mtlsAuthorizedClientRegistry = mtlsAuthorizedClientRegistry
|
|
296
|
+
self._peerCertAcceptanceHook = peer_cert_acceptance_hook
|
|
285
297
|
|
|
286
298
|
def _resolveMtlsClientIdentity(self, request, vortexSession: VortexSession) -> None:
|
|
287
299
|
"""Resolve the mTLS client identity onto the vortex session.
|
|
@@ -289,10 +301,14 @@ class VortexWebSocketUpgradeResource(resource.Resource):
|
|
|
289
301
|
When an authorized-client registry is present and the TLS
|
|
290
302
|
transport carries a peer certificate, the peer public key is
|
|
291
303
|
resolved to its :class:`AuthorizedClientIdentity` and stored on
|
|
292
|
-
the session before the WebSocket upgrade completes.
|
|
304
|
+
the session before the WebSocket upgrade completes. When the
|
|
305
|
+
registry does not hold the peer key, the peer certificate
|
|
306
|
+
acceptance hook decides identity resolution — an accepted peer
|
|
307
|
+
surfaces an identity derived from its certificate. The session
|
|
293
308
|
attribute stays ``None`` when no registry is passed, the
|
|
294
|
-
listener is not mTLS, or the
|
|
295
|
-
|
|
309
|
+
listener is not mTLS, or neither the registry nor the acceptance
|
|
310
|
+
hook authorizes the peer
|
|
311
|
+
(``_MtlsServerCertificateOptions._verifyPeer`` already rejects
|
|
296
312
|
unauthorized keys during the TLS handshake).
|
|
297
313
|
"""
|
|
298
314
|
if self._mtlsAuthorizedClientRegistry is None:
|
|
@@ -311,6 +327,10 @@ class VortexWebSocketUpgradeResource(resource.Resource):
|
|
|
311
327
|
identity = self._mtlsAuthorizedClientRegistry.getIdentityForPublicKey(
|
|
312
328
|
publicKeyAsn1
|
|
313
329
|
)
|
|
330
|
+
if identity is None:
|
|
331
|
+
identity = self._resolveIdentityFromAcceptanceHook(
|
|
332
|
+
peerCertificate, publicKeyAsn1
|
|
333
|
+
)
|
|
314
334
|
if identity is None:
|
|
315
335
|
logger.warning(
|
|
316
336
|
"mTLS vortex websocket peer public key is not authorized;"
|
|
@@ -320,6 +340,57 @@ class VortexWebSocketUpgradeResource(resource.Resource):
|
|
|
320
340
|
|
|
321
341
|
vortexSession.mtlsClientIdentity = identity
|
|
322
342
|
|
|
343
|
+
def _resolveIdentityFromAcceptanceHook(
|
|
344
|
+
self,
|
|
345
|
+
peerCertificate: crypto.X509,
|
|
346
|
+
publicKeyAsn1: bytes,
|
|
347
|
+
) -> AuthorizedClientIdentity | None:
|
|
348
|
+
"""Resolve an identity for a peer the acceptance hook accepts.
|
|
349
|
+
|
|
350
|
+
The registry authorizes hub-issued certificates by public key;
|
|
351
|
+
peers the TLS acceptance hook accepted (for example
|
|
352
|
+
broker-issued leaves chaining to a pinned intermediate) surface
|
|
353
|
+
an identity derived from their certificate — the common name
|
|
354
|
+
identifies the peer and the fingerprint supports audit logs.
|
|
355
|
+
|
|
356
|
+
:param peerCertificate: The peer leaf certificate from the TLS
|
|
357
|
+
transport
|
|
358
|
+
:param publicKeyAsn1: The peer public key, DER encoded
|
|
359
|
+
:return: The derived identity, or ``None`` when no hook is
|
|
360
|
+
wired or the hook rejects the peer
|
|
361
|
+
"""
|
|
362
|
+
acceptanceHook = self._peerCertAcceptanceHook
|
|
363
|
+
if acceptanceHook is None:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
certificatePem = crypto.dump_certificate(
|
|
367
|
+
crypto.FILETYPE_PEM, peerCertificate
|
|
368
|
+
).decode("ascii")
|
|
369
|
+
if not acceptanceHook(certificatePem):
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
fingerprint = hashlib.sha256(
|
|
373
|
+
crypto.dump_certificate(crypto.FILETYPE_ASN1, peerCertificate)
|
|
374
|
+
).hexdigest()[:16]
|
|
375
|
+
subject = peerCertificate.get_subject()
|
|
376
|
+
commonName = (
|
|
377
|
+
subject.CN.decode("utf-8")
|
|
378
|
+
if isinstance(subject.CN, bytes)
|
|
379
|
+
else str(subject.CN or "")
|
|
380
|
+
)
|
|
381
|
+
logger.info(
|
|
382
|
+
"mTLS peer accepted via certificate acceptance hook cn=%s fingerprint=%s",
|
|
383
|
+
commonName,
|
|
384
|
+
fingerprint,
|
|
385
|
+
)
|
|
386
|
+
return AuthorizedClientIdentity(
|
|
387
|
+
fingerprint=fingerprint,
|
|
388
|
+
publicKeyAsn1=publicKeyAsn1,
|
|
389
|
+
instanceId=commonName,
|
|
390
|
+
ownerUsername="",
|
|
391
|
+
commonName=commonName,
|
|
392
|
+
)
|
|
393
|
+
|
|
323
394
|
@staticmethod
|
|
324
395
|
def _isWebsocketUpgrade(request) -> bool:
|
|
325
396
|
connection = b"".join(
|