Skip to content

Commit 633c7dc

Browse files
nicklaslclaude
andcommitted
feat(python): support encrypted CDN resolver state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d33565 commit 633c7dc

7 files changed

Lines changed: 217 additions & 34 deletions

File tree

openfeature-provider/python/src/confidence/local_resolver.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class LocalResolver:
3030
and Go RecoveringResolver.
3131
"""
3232

33+
EncryptedState = Tuple[bytes, bytes, object]
34+
3335
def __init__(
3436
self,
3537
wasm_bytes: bytes,
@@ -46,6 +48,7 @@ def __init__(
4648
self._delegate_factory = delegate_factory or WasmResolver
4749
self._delegate: WasmResolver = self._delegate_factory(wasm_bytes)
4850
self._current_state: Optional[Tuple[bytes, str, object]] = None
51+
self._current_encrypted_state: Optional["LocalResolver.EncryptedState"] = None
4952
self._buffered_logs: List[bytes] = []
5053

5154
def _reload_instance(self, error: BaseException) -> None:
@@ -70,7 +73,15 @@ def _reload_instance(self, error: BaseException) -> None:
7073
self._delegate = self._delegate_factory(self._wasm_bytes)
7174

7275
# Restore state if available
73-
if self._current_state is not None:
76+
if self._current_encrypted_state is not None:
77+
encrypted_state, encryption_key, sdk = self._current_encrypted_state
78+
try:
79+
self._delegate.set_encrypted_resolver_state(
80+
encrypted_state, encryption_key, sdk
81+
)
82+
except Exception as e:
83+
logger.error("Failed to restore encrypted state after reload: %s", e)
84+
elif self._current_state is not None:
7485
state, account_id, sdk = self._current_state
7586
try:
7687
self._delegate.set_resolver_state(state, account_id, sdk)
@@ -90,12 +101,29 @@ def set_resolver_state(
90101
sdk: Optional SDK identifier and version.
91102
"""
92103
self._current_state = (state, account_id, sdk)
104+
self._current_encrypted_state = None
93105
try:
94106
self._delegate.set_resolver_state(state, account_id, sdk)
95107
except WasmCrashError as error:
96108
self._reload_instance(error)
97109
raise
98110

111+
def set_encrypted_resolver_state(
112+
self,
113+
encrypted_state: bytes,
114+
encryption_key: bytes,
115+
sdk: Optional[object] = None,
116+
) -> None:
117+
self._current_encrypted_state = (encrypted_state, encryption_key, sdk)
118+
self._current_state = None
119+
try:
120+
self._delegate.set_encrypted_resolver_state(
121+
encrypted_state, encryption_key, sdk
122+
)
123+
except WasmCrashError as error:
124+
self._reload_instance(error)
125+
raise
126+
99127
def resolve_process(
100128
self, request: wasm_api_pb2.ResolveProcessRequest
101129
) -> wasm_api_pb2.ResolveProcessResponse:

openfeature-provider/python/src/confidence/proto/confidence/wasm/messages_pb2.py

Lines changed: 11 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openfeature-provider/python/src/confidence/provider.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class ConfidenceProvider(AbstractProvider):
123123
def __init__(
124124
self,
125125
client_secret: str,
126+
encryption_key: Optional[str] = None,
126127
state_poll_interval: float = DEFAULT_STATE_POLL_INTERVAL,
127128
log_poll_interval: float = DEFAULT_LOG_POLL_INTERVAL,
128129
assign_poll_interval: float = DEFAULT_ASSIGN_POLL_INTERVAL,
@@ -150,6 +151,7 @@ def __init__(
150151
wasm_bytes: Optional WASM bytes for testing.
151152
"""
152153
self._client_secret = client_secret
154+
self._encryption_key = encryption_key
153155
self._state_poll_interval = state_poll_interval
154156
self._log_poll_interval = log_poll_interval
155157
self._assign_poll_interval = assign_poll_interval
@@ -229,6 +231,7 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
229231
self._state_fetcher = StateFetcher(
230232
client_secret=self._client_secret,
231233
http_client=self._http_client,
234+
encryption_key=self._encryption_key,
232235
)
233236

234237
# Create flag logger if not injected
@@ -243,12 +246,11 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
243246
# Fetch initial state - don't fail if this fails, background thread will retry
244247
try:
245248
state, account_id, _ = self._state_fetcher.fetch()
246-
if account_id:
247-
sdk = types_pb2.Sdk(
248-
id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER,
249-
version=__version__,
250-
)
251-
self._resolver.set_resolver_state(state, account_id, sdk)
249+
sdk = types_pb2.Sdk(
250+
id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER,
251+
version=__version__,
252+
)
253+
if self._set_resolver_state(state, account_id, sdk):
252254
self._status = ProviderStatus.READY
253255
self.emit_provider_ready(ProviderEventDetails())
254256
logger.info("ConfidenceProvider initialized successfully")
@@ -311,6 +313,18 @@ def shutdown(self) -> None:
311313

312314
logger.info("ConfidenceProvider shutdown complete")
313315

316+
def _set_resolver_state(self, state: bytes, account_id: str, sdk: object) -> bool:
317+
if self._encryption_key and self._state_fetcher is not None:
318+
cdn_bytes = getattr(self._state_fetcher, "raw_cdn_bytes", None)
319+
if cdn_bytes is not None:
320+
key_bytes = bytes.fromhex(self._encryption_key)
321+
self._resolver.set_encrypted_resolver_state(cdn_bytes, key_bytes, sdk)
322+
return True
323+
if account_id:
324+
self._resolver.set_resolver_state(state, account_id, sdk)
325+
return True
326+
return False
327+
314328
def _resolve_typed(
315329
self,
316330
flag_key: str,
@@ -786,17 +800,17 @@ def _state_poll_loop(self) -> None:
786800

787801
try:
788802
state, account_id, changed = self._state_fetcher.fetch()
789-
if changed and account_id:
803+
if changed:
790804
sdk = types_pb2.Sdk(
791805
id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER,
792806
version=__version__,
793807
)
794808
with self._resolver_lock:
795-
self._resolver.set_resolver_state(state, account_id, sdk)
809+
self._set_resolver_state(state, account_id, sdk)
796810
logger.debug("Resolver state updated")
797811

798-
# If we were NOT_READY and now have valid state, transition to READY
799-
if account_id and self._status == ProviderStatus.NOT_READY:
812+
has_state = self._encryption_key is not None or bool(account_id)
813+
if has_state and self._status == ProviderStatus.NOT_READY:
800814
self._status = ProviderStatus.READY
801815
self.emit_provider_ready(ProviderEventDetails())
802816
logger.info("Provider recovered and is now READY")

openfeature-provider/python/src/confidence/state_fetcher.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def __init__(
3939
self,
4040
client_secret: str,
4141
http_client: Optional[httpx.Client] = None,
42+
encryption_key: Optional[str] = None,
4243
) -> None:
4344
"""Initialize the StateFetcher.
4445
@@ -47,12 +48,14 @@ def __init__(
4748
http_client: Optional httpx.Client for custom HTTP configuration or testing.
4849
"""
4950
self._client_secret = client_secret
51+
self._encryption_key = encryption_key
5052
self._http_client = http_client
5153
self._owns_client = http_client is None
5254

5355
# Cached state
5456
self._state: Optional[bytes] = None
5557
self._account_id: Optional[str] = None
58+
self._raw_cdn_bytes: Optional[bytes] = None
5659
self._etag: Optional[str] = None
5760

5861
# Build CDN URL from SHA256 hash of client secret
@@ -69,6 +72,11 @@ def account_id(self) -> Optional[str]:
6972
"""Return the current cached account ID."""
7073
return self._account_id
7174

75+
@property
76+
def raw_cdn_bytes(self) -> Optional[bytes]:
77+
"""Return raw encrypted CDN bytes, or None if unencrypted."""
78+
return self._raw_cdn_bytes
79+
7280
def _get_client(self) -> httpx.Client:
7381
"""Get or create the HTTP client."""
7482
if self._http_client is not None:
@@ -114,22 +122,36 @@ def fetch(self) -> Tuple[bytes, str, bool]:
114122
f"Failed to fetch state: HTTP {response.status_code}"
115123
)
116124

117-
# Parse the protobuf response
118-
state_request = SetResolverStateRequest()
119-
state_request.ParseFromString(response.content)
120-
121-
# Cache the state, account ID, and ETag
122-
self._state = state_request.state
123-
self._account_id = state_request.account_id
125+
encrypted = response.headers.get("x-amz-meta-encrypted") == "true"
124126
self._etag = response.headers.get("ETag")
125127

126-
logger.info(
127-
"Loaded resolver state for account=%s, etag=%s",
128-
self._account_id,
129-
self._etag,
130-
)
131-
132-
return self._state, self._account_id, True
128+
if not encrypted:
129+
try:
130+
state_request = SetResolverStateRequest()
131+
state_request.ParseFromString(response.content)
132+
self._state = state_request.state
133+
self._account_id = state_request.account_id
134+
self._raw_cdn_bytes = None
135+
logger.info(
136+
"Loaded resolver state for account=%s, etag=%s",
137+
self._account_id,
138+
self._etag,
139+
)
140+
return self._state, self._account_id, True
141+
except Exception:
142+
logger.warning(
143+
"Protobuf decode failed, treating state as encrypted"
144+
)
145+
encrypted = True
146+
147+
if not self._encryption_key:
148+
raise StateFetcherError(
149+
"Resolver state is encrypted but no encryption_key was provided. "
150+
"Set the encryption key for this client credential."
151+
)
152+
self._raw_cdn_bytes = response.content
153+
logger.info("Loaded encrypted resolver state, etag=%s", self._etag)
154+
return self._raw_cdn_bytes, "", True
133155

134156
finally:
135157
if should_close:

openfeature-provider/python/src/confidence/wasm_resolver.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def __init__(self, wasm_bytes: bytes) -> None:
5555
self._wasm_msg_guest_set_resolver_state = exports[
5656
"wasm_msg_guest_set_resolver_state"
5757
]
58+
self._wasm_msg_guest_set_encrypted_resolver_state = exports[
59+
"wasm_msg_guest_set_encrypted_resolver_state"
60+
]
5861
self._wasm_msg_guest_resolve_flags = exports["wasm_msg_guest_resolve_flags"]
5962
self._wasm_msg_guest_bounded_flush_logs = exports[
6063
"wasm_msg_guest_bounded_flush_logs"
@@ -119,6 +122,26 @@ def set_resolver_state(
119122
if resp_ptr != 0:
120123
self._consume_response(resp_ptr, None)
121124

125+
def set_encrypted_resolver_state(
126+
self,
127+
encrypted_state: bytes,
128+
encryption_key: bytes,
129+
sdk: Optional[protobuf_message.Message] = None,
130+
) -> None:
131+
request = messages_pb2.SetEncryptedResolverStateRequest()
132+
request.encrypted_state = encrypted_state
133+
request.encryption_key = encryption_key
134+
if sdk is not None:
135+
request.sdk.CopyFrom(sdk)
136+
137+
req_ptr = self._transfer_request(request)
138+
resp_ptr = self._wasm_msg_guest_set_encrypted_resolver_state(
139+
self._store, req_ptr
140+
)
141+
142+
if resp_ptr != 0:
143+
self._consume_response(resp_ptr, None)
144+
122145
def resolve_process(
123146
self, request: wasm_api_pb2.ResolveProcessRequest
124147
) -> wasm_api_pb2.ResolveProcessResponse:

openfeature-provider/python/tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ def test_account_id() -> str:
5555
return account_path.read_text().strip()
5656

5757

58+
@pytest.fixture
59+
def test_encrypted_state() -> bytes:
60+
"""Load encrypted test resolver state."""
61+
path = get_data_dir() / "resolver_state_encrypted.pb"
62+
if not path.exists():
63+
pytest.skip(f"Encrypted test state not found at {path}")
64+
return path.read_bytes()
65+
66+
67+
@pytest.fixture
68+
def test_encryption_key() -> bytes:
69+
"""Load test encryption key (raw bytes)."""
70+
path = get_data_dir() / "encryption_key_test.hex"
71+
if not path.exists():
72+
pytest.skip(f"Test encryption key not found at {path}")
73+
return bytes.fromhex(path.read_text().strip())
74+
75+
5876
@pytest.fixture
5977
def test_client_secret() -> str:
6078
"""Test client secret for the demo account."""

0 commit comments

Comments
 (0)