vortexpy 6.2.0__tar.gz → 6.2.2__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.2.0 → vortexpy-6.2.2}/PKG-INFO +1 -1
- {vortexpy-6.2.0 → vortexpy-6.2.2}/pyproject.toml +1 -1
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexFactory.py +5 -2
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexFactory_test.py +72 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/__init__.py +1 -1
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataOfflineObserverService.py +9 -39
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataOfflineObserverService_test.py +1 -1
- vortexpy-6.2.2/vortex/storage/TupleOfflineStorageSecureService.py +201 -0
- vortexpy-6.2.2/vortex/storage/TupleOfflineStorageSecureService_test.py +252 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleStorageServiceABC.py +5 -3
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortexpy.egg-info/PKG-INFO +1 -1
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortexpy.egg-info/SOURCES.txt +2 -2
- vortexpy-6.2.0/vortex/handler/TupleDataSecureOfflineObserverService.py +0 -146
- vortexpy-6.2.0/vortex/handler/TupleDataSecureOfflineObserverService_test.py +0 -314
- {vortexpy-6.2.0 → vortexpy-6.2.2}/LICENSE +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/README.md +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/TwistedDeferredTimeout.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_http_upgraded_websocket_ping_client.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_http_upgraded_websocket_ping_server.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/run_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/setup_example_logging.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/example/store_and_load_json.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/setup.cfg +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/DeferUtil.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/DeferUtil_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/Jsonable.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/MtlsServerSslOptions.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/Payload.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadIO.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadPriority.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadResponse.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PayloadTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/SerialiseUtil.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/SerialiseUtil_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TestTuple.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/Tuple.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleAction.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleBson.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleBson_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleDocumentConverter.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleRestful.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleRestful_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleSelector.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/TupleTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexClientTcp.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexClientWebsocketFactory.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexPermission.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServer.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerConnection.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerConnection_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerTcp.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerTcp_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerWebsocket.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServerWebsocket_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexServer_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexSession.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexSession_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexUserDetailsABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexUtil.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/VortexWritePushProducer_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLiveLockManager_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionProcessorProxy.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionProcessor_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionPushOffline.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleActionPushOffline_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataActionClient_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableCache.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableCache_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableHandler_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableProxyHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataObserverClient.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/handler/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/mypy_plugin.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/payload_many_tuples_benchmark_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/py.typed +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/restful_http_client_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/restful/restful_resource_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/rpc/RPC.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/rpc/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleActionStorageSqlite.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleOfflineStorageNameService.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleOfflineStorageService.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleStorageSqliteService.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/TupleStorageSqliteService_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/storage/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/tuple_test_fixtures.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/twisted_runtime_metrics.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortex/twisted_runtime_metrics_test.py +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/vortexpy.egg-info/requires.txt +0 -0
- {vortexpy-6.2.0 → vortexpy-6.2.2}/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.2.
|
|
7
|
+
version = "6.2.2"
|
|
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."
|
|
@@ -995,7 +995,10 @@ class VortexFactory:
|
|
|
995
995
|
"Vortex %s went %s", vortexName, ("online" if online else "offline")
|
|
996
996
|
)
|
|
997
997
|
|
|
998
|
+
# Rebuild the connection structs before notifying: handlers react to
|
|
999
|
+
# the status change synchronously (e.g. the offline observer re-tell)
|
|
1000
|
+
# and read getRemoteVortexName() from these structs.
|
|
1001
|
+
cls.connectionChanged()
|
|
1002
|
+
|
|
998
1003
|
if vortexName in cls.__vortexStatusChangeSubjectsByName:
|
|
999
1004
|
cls.__vortexStatusChangeSubjectsByName[vortexName].on_next(online)
|
|
1000
|
-
|
|
1001
|
-
cls.connectionChanged()
|
|
@@ -112,3 +112,75 @@ def test_closeExistingVortexClientsForName_closesRegisteredClients() -> None:
|
|
|
112
112
|
assert (
|
|
113
113
|
VortexFactory.getLocalVortexClients("codie-mcp-local") == []
|
|
114
114
|
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resetVortexFactoryClassState() -> None:
|
|
118
|
+
VortexFactory._VortexFactory__vortexInfoState = _VortexFactoryVortexInfo()
|
|
119
|
+
VortexFactory._VortexFactory__vortexStatusChangeSubjectsByName = {}
|
|
120
|
+
VortexFactory._VortexFactory__isShutdown = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_notifyOfVortexStatusChange_rebuildsStructsBeforeNotify() -> None:
|
|
124
|
+
_resetVortexFactoryClassState()
|
|
125
|
+
try:
|
|
126
|
+
client = _StubVortexClient("codie-mcp-local", "local-uuid-1")
|
|
127
|
+
VortexFactory._VortexFactory__vortexInfoState.addVortexClient(
|
|
128
|
+
"codie-mcp-local", client
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Mirror the handshake: _setNameAndUuid sets the remote info on the
|
|
132
|
+
# client before _notifyOfVortexStatusChange runs.
|
|
133
|
+
client._remote_name = "codie-hub"
|
|
134
|
+
client._remote_uuid = "hub-uuid-1"
|
|
135
|
+
|
|
136
|
+
notifications = []
|
|
137
|
+
VortexFactory.subscribeToVortexStatusChange("codie-hub").subscribe(
|
|
138
|
+
lambda online: notifications.append(
|
|
139
|
+
(online, VortexFactory.getRemoteVortexName())
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
VortexFactory._notifyOfVortexStatusChange("codie-hub", online=True)
|
|
144
|
+
|
|
145
|
+
assert len(notifications) == 1
|
|
146
|
+
online, remoteNames = notifications[0]
|
|
147
|
+
assert online is True
|
|
148
|
+
assert "codie-hub" in remoteNames
|
|
149
|
+
finally:
|
|
150
|
+
_resetVortexFactoryClassState()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_notifyOfVortexStatusChange_offlineAfterClearDropsName() -> None:
|
|
154
|
+
_resetVortexFactoryClassState()
|
|
155
|
+
try:
|
|
156
|
+
client = _StubVortexClient(
|
|
157
|
+
"codie-mcp-local",
|
|
158
|
+
"local-uuid-1",
|
|
159
|
+
remote_name="codie-hub",
|
|
160
|
+
remote_uuid="hub-uuid-1",
|
|
161
|
+
)
|
|
162
|
+
VortexFactory._VortexFactory__vortexInfoState.addVortexClient(
|
|
163
|
+
"codie-mcp-local", client
|
|
164
|
+
)
|
|
165
|
+
assert "codie-hub" in VortexFactory.getRemoteVortexName()
|
|
166
|
+
|
|
167
|
+
# Mirror connectionLost: _clearRemoteVortexInfo clears the remote
|
|
168
|
+
# info on the client before _notifyOfVortexStatusChange runs.
|
|
169
|
+
client._remote_name = None
|
|
170
|
+
client._remote_uuid = None
|
|
171
|
+
|
|
172
|
+
notifications = []
|
|
173
|
+
VortexFactory.subscribeToVortexStatusChange("codie-hub").subscribe(
|
|
174
|
+
lambda online: notifications.append(
|
|
175
|
+
(online, VortexFactory.getRemoteVortexName())
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
VortexFactory._notifyOfVortexStatusChange("codie-hub", online=False)
|
|
180
|
+
|
|
181
|
+
assert len(notifications) == 1
|
|
182
|
+
online, remoteNames = notifications[0]
|
|
183
|
+
assert online is False
|
|
184
|
+
assert "codie-hub" not in remoteNames
|
|
185
|
+
finally:
|
|
186
|
+
_resetVortexFactoryClassState()
|
|
@@ -432,7 +432,10 @@ class TupleDataOfflineObserverService:
|
|
|
432
432
|
self, cachedData: CachedSubscribedData, tupleSelector: TupleSelector
|
|
433
433
|
) -> Generator[Any, Any, None]:
|
|
434
434
|
"""Load the stored payload, decode it, and notify (TS :373-409)."""
|
|
435
|
-
|
|
435
|
+
assert self._offlineStorage is not None, (
|
|
436
|
+
"offlineStorage must be set before loading tuples from storage"
|
|
437
|
+
)
|
|
438
|
+
vortexMsg = yield self._offlineStorage.loadTuplesEncoded(tupleSelector)
|
|
436
439
|
|
|
437
440
|
# There is no data
|
|
438
441
|
if vortexMsg is None:
|
|
@@ -444,9 +447,9 @@ class TupleDataOfflineObserverService:
|
|
|
444
447
|
return
|
|
445
448
|
|
|
446
449
|
assert isinstance(vortexMsg, str), (
|
|
447
|
-
f"Expected a plaintext str payload from storage, got"
|
|
448
|
-
f" {type(vortexMsg).__name__} — ciphered storage must be
|
|
449
|
-
f"
|
|
450
|
+
f"Expected a plaintext str payload from offline storage, got"
|
|
451
|
+
f" {type(vortexMsg).__name__} — ciphered storage must be wrapped"
|
|
452
|
+
f" in TupleOfflineStorageSecureService so reads return plaintext"
|
|
450
453
|
)
|
|
451
454
|
payload = yield Payload().fromEncodedPayloadDefer(vortexMsg)
|
|
452
455
|
|
|
@@ -460,39 +463,6 @@ class TupleDataOfflineObserverService:
|
|
|
460
463
|
cachedData.tuples = payload.tuples
|
|
461
464
|
self._notifyObservers(cachedData, tupleSelector, payload.tuples)
|
|
462
465
|
|
|
463
|
-
def _storageLoadTuplesEncoded(self, tupleSelector: TupleSelector) -> Deferred:
|
|
464
|
-
"""Storage load hook.
|
|
465
|
-
|
|
466
|
-
``TupleDataSecureOfflineObserverService`` overrides this to run the
|
|
467
|
-
payload through the cipher delegate after reading from storage.
|
|
468
|
-
"""
|
|
469
|
-
assert self._offlineStorage is not None, (
|
|
470
|
-
"offlineStorage must be set before loading tuples from storage"
|
|
471
|
-
)
|
|
472
|
-
return self._offlineStorage.loadTuplesEncoded(tupleSelector)
|
|
473
|
-
|
|
474
|
-
def _storageSaveTuplesEncoded(
|
|
475
|
-
self, tupleSelector: TupleSelector, vortexMsg: str
|
|
476
|
-
) -> Deferred:
|
|
477
|
-
"""Storage save hook.
|
|
478
|
-
|
|
479
|
-
``TupleDataSecureOfflineObserverService`` overrides this to run the
|
|
480
|
-
payload through the cipher delegate before writing to storage.
|
|
481
|
-
"""
|
|
482
|
-
assert self._offlineStorage is not None, (
|
|
483
|
-
"offlineStorage must be set before saving encoded tuples to storage"
|
|
484
|
-
)
|
|
485
|
-
return self._offlineStorage.saveTuplesEncoded(tupleSelector, vortexMsg)
|
|
486
|
-
|
|
487
|
-
def _storageSaveTuples(
|
|
488
|
-
self, tupleSelector: TupleSelector, tuples: list[Tuple]
|
|
489
|
-
) -> Deferred:
|
|
490
|
-
"""Storage save hook for unencoded tuples."""
|
|
491
|
-
assert self._offlineStorage is not None, (
|
|
492
|
-
"offlineStorage must be set before saving tuples to storage"
|
|
493
|
-
)
|
|
494
|
-
return self._offlineStorage.saveTuples(tupleSelector, tuples)
|
|
495
|
-
|
|
496
466
|
def _cleanupDeadCaches(self) -> None:
|
|
497
467
|
for tsStr in list(self._cacheByTupleSelector.keys()):
|
|
498
468
|
cachedData = self._cacheByTupleSelector[tsStr]
|
|
@@ -738,6 +708,6 @@ class TupleDataOfflineObserverService:
|
|
|
738
708
|
return succeed(None)
|
|
739
709
|
|
|
740
710
|
if encodedPayload is None:
|
|
741
|
-
return self.
|
|
711
|
+
return self._offlineStorage.saveTuples(tupleSelector, tuples)
|
|
742
712
|
|
|
743
|
-
return self.
|
|
713
|
+
return self._offlineStorage.saveTuplesEncoded(tupleSelector, encodedPayload)
|
|
@@ -250,7 +250,7 @@ async def test_stored_data_loses_to_live_payload(tmp_path, monkeypatch) -> None:
|
|
|
250
250
|
loadChains.append(deferred)
|
|
251
251
|
return deferred
|
|
252
252
|
|
|
253
|
-
monkeypatch.setattr(
|
|
253
|
+
monkeypatch.setattr(storage, "loadTuplesEncoded", gatedLoad)
|
|
254
254
|
monkeypatch.setattr(
|
|
255
255
|
service, "_loadStoredTuplesDefer", capturingLoadStoredTuplesDefer
|
|
256
256
|
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# ruff: noqa: N999
|
|
2
|
+
# Module names use PascalCase for vortexjs member-for-member parity.
|
|
3
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
4
|
+
#
|
|
5
|
+
# This software is open source; the MIT license applies.
|
|
6
|
+
# See LICENSE for the full license text.
|
|
7
|
+
#
|
|
8
|
+
# Website : https://flyingion.com
|
|
9
|
+
# Support : contact@flyingion.com
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from abc import ABCMeta, abstractmethod
|
|
16
|
+
from collections.abc import Generator
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from twisted.internet.defer import Deferred, inlineCallbacks
|
|
20
|
+
|
|
21
|
+
from vortex.Payload import Payload
|
|
22
|
+
from vortex.storage.TupleOfflineStorageService import TupleOfflineStorageService
|
|
23
|
+
from vortex.storage.TupleStorageServiceABC import TupleStorageBatchSaveArguments
|
|
24
|
+
from vortex.storage.TupleStorageSqliteService import TupleStorageSqliteService
|
|
25
|
+
from vortex.Tuple import Tuple
|
|
26
|
+
from vortex.TupleSelector import TupleSelector
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OfflineStorageCipherABC(metaclass=ABCMeta):
|
|
32
|
+
"""Offline Storage Cipher ABC
|
|
33
|
+
|
|
34
|
+
Cipher delegate for executor-local storage encryption. The concrete cipher
|
|
35
|
+
is supplied by the consumer — every agentic holds the only key to its own
|
|
36
|
+
cached data and there is no shared or hub-side key to protect.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
41
|
+
"""Encrypt plaintext bytes.
|
|
42
|
+
|
|
43
|
+
:param plaintext: The plaintext to encrypt.
|
|
44
|
+
:return: The ciphertext bytes.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
49
|
+
"""Decrypt ciphertext bytes.
|
|
50
|
+
|
|
51
|
+
:param ciphertext: The ciphertext to decrypt.
|
|
52
|
+
:return: The plaintext bytes.
|
|
53
|
+
:raises Exception: when the ciphertext is not readable by this key
|
|
54
|
+
(cert rotated, wrong key, corrupt row).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TupleOfflineStorageSecureService(TupleOfflineStorageService):
|
|
59
|
+
"""Tuple Offline Storage Secure Service
|
|
60
|
+
|
|
61
|
+
A ``TupleOfflineStorageService`` facade implementation that runs payloads
|
|
62
|
+
through the cipher delegate before writing to and after reading from the
|
|
63
|
+
inner storage backend. This is executor-local storage encryption — no
|
|
64
|
+
hub-side transport encryption; the payload format on the transport is
|
|
65
|
+
untouched (mTLS already secures transport), only the bytes landing in
|
|
66
|
+
SQLite are ciphertext.
|
|
67
|
+
|
|
68
|
+
Decrypt failure on load (cert rotated, wrong key, corrupt row) is a cache
|
|
69
|
+
miss: the row is deleted and ``None`` is returned — no plaintext fallback,
|
|
70
|
+
no partial decrypt.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
storage: TupleStorageSqliteService,
|
|
76
|
+
cipher: OfflineStorageCipherABC,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Constructor
|
|
79
|
+
|
|
80
|
+
:param storage: The constructed inner storage backend; ciphertext
|
|
81
|
+
rows are written to and read from it.
|
|
82
|
+
:param cipher: The cipher delegate for storage encryption.
|
|
83
|
+
"""
|
|
84
|
+
TupleOfflineStorageService.__init__(self, storage)
|
|
85
|
+
self._cipher = cipher
|
|
86
|
+
|
|
87
|
+
@inlineCallbacks
|
|
88
|
+
def loadTuples(
|
|
89
|
+
self, tupleSelector: TupleSelector
|
|
90
|
+
) -> Generator[Any, Any, list[Tuple]]:
|
|
91
|
+
"""Load and decode the tuples stored for a selector.
|
|
92
|
+
|
|
93
|
+
Mirrors ``TupleStorageServiceABC.loadTuples`` minus the plaintext-str
|
|
94
|
+
assert — this facade's own :meth:`loadTuplesEncoded` already owns the
|
|
95
|
+
decrypt, so the decoded payload is always plaintext ``str`` here.
|
|
96
|
+
|
|
97
|
+
:param tupleSelector: The selector to load tuples for.
|
|
98
|
+
:return: A ``Deferred`` firing the stored tuples, or an empty list on
|
|
99
|
+
a lookup miss.
|
|
100
|
+
"""
|
|
101
|
+
startTime = time.monotonic()
|
|
102
|
+
|
|
103
|
+
vortexMsg = yield self.loadTuplesEncoded(tupleSelector)
|
|
104
|
+
if vortexMsg is None:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
payload = yield Payload().fromEncodedPayloadDefer(vortexMsg)
|
|
108
|
+
|
|
109
|
+
logger.debug(
|
|
110
|
+
"Secure storage: loadTuples took %sms",
|
|
111
|
+
round((time.monotonic() - startTime) * 1000),
|
|
112
|
+
)
|
|
113
|
+
return payload.tuples
|
|
114
|
+
|
|
115
|
+
@inlineCallbacks
|
|
116
|
+
def loadTuplesEncoded(
|
|
117
|
+
self, tupleSelector: TupleSelector
|
|
118
|
+
) -> Generator[Any, Any, str | None]:
|
|
119
|
+
"""Load, decrypt, and return the stored payload for a selector.
|
|
120
|
+
|
|
121
|
+
A decrypt failure (cert rotated, wrong key, corrupt row) is a cache
|
|
122
|
+
miss: the row is deleted from the inner storage and ``None`` is
|
|
123
|
+
returned.
|
|
124
|
+
|
|
125
|
+
:param tupleSelector: The selector to load the payload for.
|
|
126
|
+
:return: A ``Deferred`` firing the decrypted encoded payload, or
|
|
127
|
+
``None`` on a lookup miss.
|
|
128
|
+
"""
|
|
129
|
+
stored = yield self._storage.loadTuplesEncoded(tupleSelector)
|
|
130
|
+
if stored is None:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
ciphertext = stored.encode("utf-8") if isinstance(stored, str) else stored
|
|
134
|
+
try:
|
|
135
|
+
plaintextBytes = self._cipher.decrypt(ciphertext)
|
|
136
|
+
except Exception as exception: # noqa: BLE001 — OfflineStorageCipherABC.decrypt raises Exception by contract
|
|
137
|
+
logger.warning(
|
|
138
|
+
"Deleting undecryptable offline cache row for %s: %s",
|
|
139
|
+
tupleSelector.toJsonStr(),
|
|
140
|
+
exception,
|
|
141
|
+
)
|
|
142
|
+
yield self._storage.deleteTuples(tupleSelector)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
return plaintextBytes.decode("utf-8")
|
|
146
|
+
|
|
147
|
+
@inlineCallbacks
|
|
148
|
+
def saveTuples(
|
|
149
|
+
self, tupleSelector: TupleSelector, tuples: list[Tuple]
|
|
150
|
+
) -> Generator[Any, Any, None]:
|
|
151
|
+
"""Encode, encrypt, and store tuples for a selector.
|
|
152
|
+
|
|
153
|
+
:param tupleSelector: The selector to store tuples for.
|
|
154
|
+
:param tuples: The tuples to store.
|
|
155
|
+
:return: A ``Deferred`` that fires when the tuples are stored.
|
|
156
|
+
"""
|
|
157
|
+
startTime = time.monotonic()
|
|
158
|
+
|
|
159
|
+
vortexMsg = yield Payload(tuples=tuples).toEncodedPayloadDefer()
|
|
160
|
+
yield self.saveTuplesEncoded(tupleSelector, vortexMsg)
|
|
161
|
+
|
|
162
|
+
logger.debug(
|
|
163
|
+
"Secure storage: saveTuples took %sms",
|
|
164
|
+
round((time.monotonic() - startTime) * 1000),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def saveTuplesEncoded(
|
|
168
|
+
self, tupleSelector: TupleSelector, vortexMsg: str | bytes
|
|
169
|
+
) -> Deferred:
|
|
170
|
+
"""Encrypt and store an encoded payload for a selector.
|
|
171
|
+
|
|
172
|
+
:param tupleSelector: The selector to store the payload for.
|
|
173
|
+
:param vortexMsg: The plaintext encoded payload.
|
|
174
|
+
:return: A ``Deferred`` that fires when the payload is stored.
|
|
175
|
+
"""
|
|
176
|
+
plaintext = (
|
|
177
|
+
vortexMsg.encode("utf-8") if isinstance(vortexMsg, str) else vortexMsg
|
|
178
|
+
)
|
|
179
|
+
ciphertext = self._cipher.encrypt(plaintext)
|
|
180
|
+
return self._storage.saveTuplesEncoded(tupleSelector, ciphertext)
|
|
181
|
+
|
|
182
|
+
def batchSaveTuplesEncoded(
|
|
183
|
+
self, data: list[TupleStorageBatchSaveArguments]
|
|
184
|
+
) -> Deferred:
|
|
185
|
+
"""Encrypt and store multiple encoded payloads in one batch.
|
|
186
|
+
|
|
187
|
+
:param data: The selectors and plaintext encoded payloads to store.
|
|
188
|
+
:return: A ``Deferred`` that fires when the batch is stored.
|
|
189
|
+
"""
|
|
190
|
+
encryptedData = [
|
|
191
|
+
TupleStorageBatchSaveArguments(
|
|
192
|
+
tupleSelector=saveArguments.tupleSelector,
|
|
193
|
+
vortexMsg=self._cipher.encrypt(
|
|
194
|
+
saveArguments.vortexMsg.encode("utf-8")
|
|
195
|
+
if isinstance(saveArguments.vortexMsg, str)
|
|
196
|
+
else saveArguments.vortexMsg
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
for saveArguments in data
|
|
200
|
+
]
|
|
201
|
+
return self._storage.batchSaveTuplesEncoded(encryptedData)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# ruff: noqa: N999 C408
|
|
2
|
+
# C408: dict() with identifier keys per coding_standards/py_dict_constructor.
|
|
3
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
4
|
+
#
|
|
5
|
+
# This software is open source; the MIT license applies.
|
|
6
|
+
# See LICENSE for the full license text.
|
|
7
|
+
#
|
|
8
|
+
# Website : https://flyingion.com
|
|
9
|
+
# Support : contact@flyingion.com
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from pytest_twisted import ensureDeferred
|
|
17
|
+
|
|
18
|
+
from vortex.Payload import Payload
|
|
19
|
+
from vortex.storage.TupleOfflineStorageNameService import (
|
|
20
|
+
TupleOfflineStorageNameService,
|
|
21
|
+
)
|
|
22
|
+
from vortex.storage.TupleOfflineStorageSecureService import (
|
|
23
|
+
OfflineStorageCipherABC,
|
|
24
|
+
TupleOfflineStorageSecureService,
|
|
25
|
+
)
|
|
26
|
+
from vortex.storage.TupleStorageServiceABC import TupleStorageBatchSaveArguments
|
|
27
|
+
from vortex.storage.TupleStorageSqliteService import TupleStorageSqliteService
|
|
28
|
+
from vortex.Tuple import Tuple, TupleField, addTupleType
|
|
29
|
+
from vortex.TupleSelector import TupleSelector
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@addTupleType
|
|
33
|
+
class OfflineSecureStorageTestTuple(Tuple):
|
|
34
|
+
__tupleType__ = "vortex.test.OfflineSecureStorageTestTuple"
|
|
35
|
+
anInt: int = TupleField()
|
|
36
|
+
aString: str = TupleField()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _XorCipher(OfflineStorageCipherABC):
|
|
40
|
+
"""Test cipher: a key byte prefix followed by an XOR'd body.
|
|
41
|
+
|
|
42
|
+
Decrypt raises when the prefix does not match this cipher's key, standing
|
|
43
|
+
in for an authenticated cipher rejecting a rotated or wrong key.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, key: int) -> None:
|
|
47
|
+
self._key = key
|
|
48
|
+
|
|
49
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
50
|
+
return bytes([self._key]) + bytes(byte ^ self._key for byte in plaintext)
|
|
51
|
+
|
|
52
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
53
|
+
if not ciphertext or ciphertext[0] != self._key:
|
|
54
|
+
raise ValueError("ciphertext is not readable by this key")
|
|
55
|
+
return bytes(byte ^ self._key for byte in ciphertext[1:])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _makeSelector(key: str = "a") -> TupleSelector:
|
|
59
|
+
return TupleSelector(OfflineSecureStorageTestTuple, dict(key=key))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _makeTuples(*strings: str) -> list[OfflineSecureStorageTestTuple]:
|
|
63
|
+
return [
|
|
64
|
+
OfflineSecureStorageTestTuple(anInt=index, aString=value)
|
|
65
|
+
for index, value in enumerate(strings)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _makeInnerStorage(tmp_path: Path) -> TupleStorageSqliteService:
|
|
70
|
+
storage = TupleStorageSqliteService(
|
|
71
|
+
TupleOfflineStorageNameService("hub-credential"),
|
|
72
|
+
tmp_path / "vortex-offline",
|
|
73
|
+
)
|
|
74
|
+
await storage.open()
|
|
75
|
+
return storage
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.twisted
|
|
79
|
+
@ensureDeferred
|
|
80
|
+
async def test_stored_bytes_are_ciphertext(tmp_path: Path) -> None:
|
|
81
|
+
innerStorage = await _makeInnerStorage(tmp_path)
|
|
82
|
+
cipher = _XorCipher(0x5A)
|
|
83
|
+
secureStorage = TupleOfflineStorageSecureService(innerStorage, cipher)
|
|
84
|
+
tupleSelector = _makeSelector()
|
|
85
|
+
|
|
86
|
+
await secureStorage.saveTuples(tupleSelector, _makeTuples("secret-payload"))
|
|
87
|
+
|
|
88
|
+
rawRow = await innerStorage.loadTuplesEncoded(tupleSelector)
|
|
89
|
+
assert isinstance(rawRow, bytes), (
|
|
90
|
+
"stored payload must be ciphertext bytes, not plaintext str"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert b"secret-payload" not in rawRow, (
|
|
94
|
+
"the stored row must not contain the plaintext payload"
|
|
95
|
+
)
|
|
96
|
+
assert b"OfflineSecureStorageTestTuple" not in rawRow, (
|
|
97
|
+
"the stored row must not contain the plaintext tuple type name"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# The row decrypts to a standard encoded payload that decodes to the
|
|
101
|
+
# stored tuples.
|
|
102
|
+
decryptedPayload = await Payload().fromEncodedPayloadDefer(
|
|
103
|
+
cipher.decrypt(rawRow).decode("utf-8")
|
|
104
|
+
)
|
|
105
|
+
assert [t.aString for t in decryptedPayload.tuples] == ["secret-payload"], (
|
|
106
|
+
"the decrypted row decodes back to the stored tuples"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.twisted
|
|
111
|
+
@ensureDeferred
|
|
112
|
+
async def test_save_tuples_encoded_and_batch_are_ciphertext(
|
|
113
|
+
tmp_path: Path,
|
|
114
|
+
) -> None:
|
|
115
|
+
innerStorage = await _makeInnerStorage(tmp_path)
|
|
116
|
+
cipher = _XorCipher(0x5A)
|
|
117
|
+
secureStorage = TupleOfflineStorageSecureService(innerStorage, cipher)
|
|
118
|
+
|
|
119
|
+
expectedBySelector: list[tuple[TupleSelector, str]] = []
|
|
120
|
+
|
|
121
|
+
encodedSelector = _makeSelector("encoded")
|
|
122
|
+
encodedPayload = await Payload(
|
|
123
|
+
tuples=_makeTuples("encoded-secret")
|
|
124
|
+
).toEncodedPayloadDefer()
|
|
125
|
+
await secureStorage.saveTuplesEncoded(encodedSelector, encodedPayload)
|
|
126
|
+
expectedBySelector.append((encodedSelector, encodedPayload))
|
|
127
|
+
|
|
128
|
+
batchData: list[TupleStorageBatchSaveArguments] = []
|
|
129
|
+
for key, value in (
|
|
130
|
+
("batch-a", "batch-secret-a"),
|
|
131
|
+
("batch-b", "batch-secret-b"),
|
|
132
|
+
):
|
|
133
|
+
batchSelector = _makeSelector(key)
|
|
134
|
+
batchPayload = await Payload(tuples=_makeTuples(value)).toEncodedPayloadDefer()
|
|
135
|
+
batchData.append(
|
|
136
|
+
TupleStorageBatchSaveArguments(
|
|
137
|
+
tupleSelector=batchSelector,
|
|
138
|
+
vortexMsg=batchPayload,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
expectedBySelector.append((batchSelector, batchPayload))
|
|
142
|
+
await secureStorage.batchSaveTuplesEncoded(batchData)
|
|
143
|
+
|
|
144
|
+
for tupleSelector, plaintext in expectedBySelector:
|
|
145
|
+
rawRow = await innerStorage.loadTuplesEncoded(tupleSelector)
|
|
146
|
+
assert isinstance(rawRow, bytes), (
|
|
147
|
+
"batch and encoded saves must store ciphertext bytes"
|
|
148
|
+
)
|
|
149
|
+
assert plaintext.encode("utf-8") not in rawRow, (
|
|
150
|
+
"the stored row must not contain the plaintext encoded payload"
|
|
151
|
+
)
|
|
152
|
+
assert cipher.decrypt(rawRow).decode("utf-8") == plaintext, (
|
|
153
|
+
"the stored row decrypts back to the original encoded payload"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.twisted
|
|
158
|
+
@ensureDeferred
|
|
159
|
+
async def test_round_trip_via_secure_facade(tmp_path: Path) -> None:
|
|
160
|
+
innerStorage = await _makeInnerStorage(tmp_path)
|
|
161
|
+
tupleSelector = _makeSelector()
|
|
162
|
+
|
|
163
|
+
firstSecure = TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x5A))
|
|
164
|
+
await firstSecure.saveTuples(tupleSelector, _makeTuples("persisted"))
|
|
165
|
+
|
|
166
|
+
# A new secure facade with the same key — the service-restart case —
|
|
167
|
+
# reads the cached data back.
|
|
168
|
+
secondSecure = TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x5A))
|
|
169
|
+
tuples = await secondSecure.loadTuples(tupleSelector)
|
|
170
|
+
assert [t.aString for t in tuples] == ["persisted"], (
|
|
171
|
+
"a new facade with the same key reads the cached tuples back"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
vortexMsg = await secondSecure.loadTuplesEncoded(tupleSelector)
|
|
175
|
+
assert isinstance(vortexMsg, str), (
|
|
176
|
+
"loadTuplesEncoded returns the decrypted plaintext payload str"
|
|
177
|
+
)
|
|
178
|
+
payload = await Payload().fromEncodedPayloadDefer(vortexMsg)
|
|
179
|
+
assert [t.aString for t in payload.tuples] == ["persisted"], (
|
|
180
|
+
"the decrypted encoded payload decodes to the stored tuples"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.twisted
|
|
185
|
+
@ensureDeferred
|
|
186
|
+
async def test_decrypt_failure_deletes_row_and_returns_none(
|
|
187
|
+
tmp_path: Path,
|
|
188
|
+
) -> None:
|
|
189
|
+
innerStorage = await _makeInnerStorage(tmp_path)
|
|
190
|
+
tupleSelector = _makeSelector()
|
|
191
|
+
|
|
192
|
+
await TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x5A)).saveTuples(
|
|
193
|
+
tupleSelector, _makeTuples("old")
|
|
194
|
+
)
|
|
195
|
+
assert await innerStorage.loadTuplesEncoded(tupleSelector) is not None, (
|
|
196
|
+
"the seeded row exists before the wrong-key load"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# A facade holding the wrong key treats the row as a cache miss
|
|
200
|
+
wrongKeySecure = TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x33))
|
|
201
|
+
assert await wrongKeySecure.loadTuplesEncoded(tupleSelector) is None, (
|
|
202
|
+
"a wrong-key load treats the row as a cache miss"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# The miss deleted the undecryptable row
|
|
206
|
+
assert await innerStorage.loadTuplesEncoded(tupleSelector) is None, (
|
|
207
|
+
"the miss deleted the undecryptable row"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# loadTuples on a decrypt failure is the same miss: row deleted, []
|
|
211
|
+
await TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x5A)).saveTuples(
|
|
212
|
+
tupleSelector, _makeTuples("old")
|
|
213
|
+
)
|
|
214
|
+
assert await wrongKeySecure.loadTuples(tupleSelector) == [], (
|
|
215
|
+
"loadTuples on a decrypt failure returns an empty list"
|
|
216
|
+
)
|
|
217
|
+
assert await innerStorage.loadTuplesEncoded(tupleSelector) is None, (
|
|
218
|
+
"the loadTuples miss also deleted the undecryptable row"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.twisted
|
|
223
|
+
@ensureDeferred
|
|
224
|
+
async def test_rotated_key_makes_old_cache_unreadable(
|
|
225
|
+
tmp_path: Path,
|
|
226
|
+
) -> None:
|
|
227
|
+
innerStorage = await _makeInnerStorage(tmp_path)
|
|
228
|
+
tupleSelector = _makeSelector()
|
|
229
|
+
|
|
230
|
+
# Data cached before the key rotation
|
|
231
|
+
await TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x5A)).saveTuples(
|
|
232
|
+
tupleSelector, _makeTuples("before-rotation")
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# After the rotation the old cache is a miss, not silently served
|
|
236
|
+
afterRotation = TupleOfflineStorageSecureService(innerStorage, _XorCipher(0x33))
|
|
237
|
+
assert await afterRotation.loadTuplesEncoded(tupleSelector) is None, (
|
|
238
|
+
"the rotated key makes the pre-rotation cache a miss"
|
|
239
|
+
)
|
|
240
|
+
assert await innerStorage.loadTuplesEncoded(tupleSelector) is None, (
|
|
241
|
+
"the rotation miss deleted the pre-rotation row"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Re-cached data is encrypted with the rotated key and reads back
|
|
245
|
+
await afterRotation.saveTuples(tupleSelector, _makeTuples("after-rotation"))
|
|
246
|
+
rawRow = await innerStorage.loadTuplesEncoded(tupleSelector)
|
|
247
|
+
assert isinstance(rawRow, bytes), "the re-cached row is stored as ciphertext bytes"
|
|
248
|
+
assert rawRow[0] == 0x33, "re-cached payload must be encrypted with the rotated key"
|
|
249
|
+
tuples = await afterRotation.loadTuples(tupleSelector)
|
|
250
|
+
assert [t.aString for t in tuples] == ["after-rotation"], (
|
|
251
|
+
"data cached after the rotation reads back with the rotated key"
|
|
252
|
+
)
|
|
@@ -33,10 +33,12 @@ class TupleStorageBatchSaveArguments(NamedTuple):
|
|
|
33
33
|
"""Batch save argument bundle.
|
|
34
34
|
|
|
35
35
|
Mirror of the vortexjs ``TupleStorageBatchSaveArguments`` interface.
|
|
36
|
+
``vortexMsg`` is ``str`` for plaintext (vortexjs parity), ``bytes``
|
|
37
|
+
when a ciphering consumer stores ciphertext.
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
40
|
tupleSelector: TupleSelector
|
|
39
|
-
vortexMsg: str
|
|
41
|
+
vortexMsg: str | bytes
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
class TupleStorageServiceABC(metaclass=ABCMeta):
|
|
@@ -97,8 +99,8 @@ class TupleStorageServiceABC(metaclass=ABCMeta):
|
|
|
97
99
|
|
|
98
100
|
assert isinstance(vortexMsg, str), (
|
|
99
101
|
f"loadTuples expects a plaintext str payload from storage, got"
|
|
100
|
-
f" {type(vortexMsg).__name__} — ciphered storage must be
|
|
101
|
-
f"
|
|
102
|
+
f" {type(vortexMsg).__name__} — ciphered storage must be wrapped"
|
|
103
|
+
f" in TupleOfflineStorageSecureService so reads return plaintext"
|
|
102
104
|
)
|
|
103
105
|
payload = yield Payload().fromEncodedPayloadDefer(vortexMsg)
|
|
104
106
|
|