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.
Files changed (161) hide show
  1. {vortexpy-6.3.1 → vortexpy-6.4.0}/PKG-INFO +1 -1
  2. {vortexpy-6.3.1 → vortexpy-6.4.0}/pyproject.toml +1 -1
  3. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsServerSslOptions.py +76 -43
  4. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexFactory.py +16 -0
  5. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerTcp.py +82 -5
  6. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerTcp_test.py +56 -0
  7. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerWebsocket.py +75 -4
  8. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerWebsocket_test.py +75 -0
  9. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/__init__.py +1 -1
  10. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableProxyHandler.py +13 -5
  11. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/twisted_runtime_metrics.py +105 -60
  12. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/twisted_runtime_metrics_test.py +34 -9
  13. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/PKG-INFO +1 -1
  14. {vortexpy-6.3.1 → vortexpy-6.4.0}/README.md +0 -0
  15. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/TwistedDeferredTimeout.py +0 -0
  16. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/__init__.py +0 -0
  17. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_ping_client.py +0 -0
  18. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_ping_server.py +0 -0
  19. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
  20. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
  21. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_websocket_rpc_test_client.py +0 -0
  22. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/run_websocket_rpc_test_server.py +0 -0
  23. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/setup_example_logging.py +0 -0
  24. {vortexpy-6.3.1 → vortexpy-6.4.0}/example/store_and_load_json.py +0 -0
  25. {vortexpy-6.3.1 → vortexpy-6.4.0}/setup.cfg +0 -0
  26. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DataWrapTuple.py +0 -0
  27. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DeferUtil.py +0 -0
  28. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/DeferUtil_test.py +0 -0
  29. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Jsonable.py +0 -0
  30. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Jsonable_tuple_dict_coercion_test.py +0 -0
  31. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
  32. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
  33. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Payload.py +0 -0
  34. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEndpoint.py +0 -0
  35. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEndpointTest.py +0 -0
  36. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadEnvelope.py +0 -0
  37. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadFilterKeys.py +0 -0
  38. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadIO.py +0 -0
  39. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadPriority.py +0 -0
  40. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponse.py +0 -0
  41. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponseTest.py +0 -0
  42. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadResponse_test.py +0 -0
  43. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PayloadTest.py +0 -0
  44. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/PerformTestActionTuple.py +0 -0
  45. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/SerialiseUtil.py +0 -0
  46. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/SerialiseUtil_test.py +0 -0
  47. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TestTuple.py +0 -0
  48. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/Tuple.py +0 -0
  49. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleAction.py +0 -0
  50. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleBson.py +0 -0
  51. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleBson_test.py +0 -0
  52. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleDataForTest.py +0 -0
  53. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleDocumentConverter.py +0 -0
  54. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TuplePerfTest.py +0 -0
  55. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
  56. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleRestful.py +0 -0
  57. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleRestful_test.py +0 -0
  58. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleSelector.py +0 -0
  59. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/TupleTest.py +0 -0
  60. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexABC.py +0 -0
  61. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexAccessControlContextABC.py +0 -0
  62. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexAuthenticatorABC.py +0 -0
  63. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientHttp.py +0 -0
  64. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientHttpTest.py +0 -0
  65. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientTcp.py +0 -0
  66. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexClientWebsocketFactory.py +0 -0
  67. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexConnectionABC.py +0 -0
  68. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexEndpointPermission.py +0 -0
  69. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexFactory_test.py +0 -0
  70. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPayloadProtocol.py +0 -0
  71. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPermission.py +0 -0
  72. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexPermissionError.py +0 -0
  73. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServer.py +0 -0
  74. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerConnection.py +0 -0
  75. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerConnection_test.py +0 -0
  76. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServerHttpResource.py +0 -0
  77. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexServer_test.py +0 -0
  78. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexSession.py +0 -0
  79. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexSession_test.py +0 -0
  80. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
  81. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
  82. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexTest.py +0 -0
  83. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUserDetailsABC.py +0 -0
  84. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUtil.py +0 -0
  85. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexUtil_test.py +0 -0
  86. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexWritePushProducer.py +0 -0
  87. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/VortexWritePushProducer_test.py +0 -0
  88. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
  89. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLiveLockManager_test.py +0 -0
  90. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoader.py +0 -0
  91. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
  92. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
  93. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
  94. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/data_loader/__init__.py +0 -0
  95. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventABC.py +0 -0
  96. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventBus.py +0 -0
  97. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
  98. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/event_bus/__init__.py +0 -0
  99. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor.py +0 -0
  100. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessorProxy.py +0 -0
  101. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
  102. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
  103. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionProcessor_test.py +0 -0
  104. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionPushOffline.py +0 -0
  105. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleActionPushOffline_test.py +0 -0
  106. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
  107. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataActionClient.py +0 -0
  108. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataActionClient_test.py +0 -0
  109. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableCache.py +0 -0
  110. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableCache_test.py +0 -0
  111. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableHandler.py +0 -0
  112. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableHandler_test.py +0 -0
  113. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
  114. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
  115. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataObserverClient.py +0 -0
  116. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataOfflineObserverService.py +0 -0
  117. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataOfflineObserverService_test.py +0 -0
  118. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/TupleDataProviderABC.py +0 -0
  119. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
  120. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/handler/__init__.py +0 -0
  121. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/mypy_plugin.py +0 -0
  122. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/payload_many_tuples_benchmark_test.py +0 -0
  123. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/py.typed +0 -0
  124. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedDataHttpClient.py +0 -0
  125. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
  126. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
  127. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/RestfulHttpClient.py +0 -0
  128. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/RestfulResource.py +0 -0
  129. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/__init__.py +0 -0
  130. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/restful_http_client_test.py +0 -0
  131. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/restful/restful_resource_test.py +0 -0
  132. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPC.py +0 -0
  133. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPCTest.py +0 -0
  134. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/RPC_test.py +0 -0
  135. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/rpc/__init__.py +0 -0
  136. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
  137. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
  138. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/VortexTupleJson.py +0 -0
  139. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/sqla_orm/__init__.py +0 -0
  140. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleActionStorageSqlite.py +0 -0
  141. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageNameService.py +0 -0
  142. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageSecureService.py +0 -0
  143. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageSecureService_test.py +0 -0
  144. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleOfflineStorageService.py +0 -0
  145. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageServiceABC.py +0 -0
  146. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqlite.py +0 -0
  147. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqliteService.py +0 -0
  148. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/TupleStorageSqliteService_test.py +0 -0
  149. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/storage/__init__.py +0 -0
  150. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/__init__.py +0 -0
  151. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/constants.py +0 -0
  152. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
  153. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
  154. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
  155. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
  156. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortex/tuple_test_fixtures.py +0 -0
  157. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/SOURCES.txt +0 -0
  158. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/dependency_links.txt +0 -0
  159. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/entry_points.txt +0 -0
  160. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/requires.txt +0 -0
  161. {vortexpy-6.3.1 → vortexpy-6.4.0}/vortexpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexpy
3
- Version: 6.3.1
3
+ Version: 6.4.0
4
4
  Summary: Flying Ion's observable, routable, data serialisation and transport code.
5
5
  Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexpy"
7
- version = "6.3.1"
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 typing import Optional
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
- registry = self._authorizedClientRegistry
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: Optional[list[bytes]] = None,
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 MtlsAuthorizedClientRegistry
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. The
98
- session attribute stays ``None`` when no registry is passed, the
99
- listener is not mTLS, or the peer key is not authorized
100
- (``MtlsServerSslOptions.verifyCallback`` already rejects
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 MtlsAuthorizedClientRegistry
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. The session
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 peer key is not authorized
295
- (``MtlsServerSslOptions.verifyCallback`` already rejects
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(