Skip to content

Commit d70844e

Browse files
nicklaslclaude
andcommitted
feat(rust): support encrypted CDN resolver state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 694c453 commit d70844e

3 files changed

Lines changed: 137 additions & 8 deletions

File tree

openfeature-provider/rust/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "spotify-confidence-openfeature-provider-local"
3-
version = "0.6.1"
3+
version = "0.6.0"
44
edition = "2021"
55
description = "OpenFeature provider for Confidence using native Rust resolver"
66
license = "Apache-2.0"
@@ -29,9 +29,10 @@ reqwest = { version = "0.13.1" }
2929
reqwest-middleware = "0.5.0"
3030
http = "1.0.0"
3131

32-
# Cryptography for CDN URL hash
32+
# Cryptography for CDN URL hash and state decryption
3333
sha2 = "0.10"
3434
hex = "0.4"
35+
aes-gcm = "0.10"
3536

3637
# Protobuf and bytes
3738
prost = "0.12"

openfeature-provider/rust/src/provider.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ pub struct ProviderOptions {
8585
/// Confidence endpoints. The original host is preserved in the `X-Forwarded-Host` header.
8686
/// This is useful for routing through a proxy or mock server.
8787
pub gateway_url: Option<String>,
88+
/// Hex-encoded AES-256 encryption key for decrypting CDN state.
89+
pub encryption_key: Option<String>,
8890
}
8991

9092
impl ProviderOptions {
@@ -98,6 +100,7 @@ impl ProviderOptions {
98100
assign_flush_interval: None,
99101
materialization_store: None,
100102
gateway_url: None,
103+
encryption_key: None,
101104
}
102105
}
103106

@@ -130,6 +133,12 @@ impl ProviderOptions {
130133
self.gateway_url = Some(url.into());
131134
self
132135
}
136+
137+
/// Set the hex-encoded AES-256 encryption key for decrypting CDN state.
138+
pub fn with_encryption_key(mut self, key: impl Into<String>) -> Self {
139+
self.encryption_key = Some(key.into());
140+
self
141+
}
133142
}
134143

135144
/// OpenFeature provider for Confidence using native Rust resolver.
@@ -163,6 +172,7 @@ impl ConfidenceProvider {
163172
client.clone(),
164173
options.client_secret.clone(),
165174
Some(sdk.clone()),
175+
options.encryption_key,
166176
));
167177
let log_manager = Arc::new(LogManager::new(
168178
client.clone(),

openfeature-provider/rust/src/state.rs

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,30 @@ pub struct StateFetcher {
3535
cdn_url: String,
3636
etag: RwLock<Option<String>>,
3737
sdk: Option<Sdk>,
38+
encryption_key: Option<Vec<u8>>,
3839
}
3940

4041
impl StateFetcher {
4142
/// Create a new state fetcher with the given client, client secret, and sdk identity.
42-
pub fn new(client: ClientWithMiddleware, client_secret: String, sdk: Option<Sdk>) -> Self {
43+
pub fn new(
44+
client: ClientWithMiddleware,
45+
client_secret: String,
46+
sdk: Option<Sdk>,
47+
encryption_key_hex: Option<String>,
48+
) -> Self {
4349
let hash = Self::hash_client_secret(&client_secret);
4450
let cdn_url = format!("{}/{}", CDN_BASE_URL, hash);
51+
let encryption_key = encryption_key_hex.map(|hex_str| {
52+
hex::decode(&hex_str).expect("encryption_key must be valid hex")
53+
});
4554

4655
Self {
4756
client,
4857
client_secret,
4958
cdn_url,
5059
etag: RwLock::new(None),
5160
sdk,
61+
encryption_key,
5262
}
5363
}
5464

@@ -100,23 +110,88 @@ impl StateFetcher {
100110
}
101111
}
102112

113+
// Check encryption header
114+
let encrypted_header = response
115+
.headers()
116+
.get("x-amz-meta-encrypted")
117+
.and_then(|v| v.to_str().ok())
118+
== Some("true");
119+
103120
// Parse response body
104-
let bytes = response.bytes().await?;
105-
let request = SetResolverStateRequest::decode(bytes).map_err(|e| {
106-
Error::StateParse(format!("Failed to decode SetResolverStateRequest: {}", e))
121+
let raw_bytes = response.bytes().await?;
122+
123+
// Try unencrypted path first (header check + protobuf fallback)
124+
let decrypted_bytes = if !encrypted_header {
125+
match SetResolverStateRequest::decode(raw_bytes.clone()) {
126+
Ok(request) => {
127+
let state_pb = ResolverStatePb::decode(request.state).map_err(|e| {
128+
Error::StateParse(format!("Failed to decode ResolverState: {}", e))
129+
})?;
130+
let state =
131+
ResolverState::from_proto(state_pb, &request.account_id, self.sdk.clone())
132+
.map_err(|e| {
133+
Error::StateParse(format!(
134+
"Failed to create ResolverState: {:?}",
135+
e
136+
))
137+
})?;
138+
return Ok(Some((state, request.account_id)));
139+
}
140+
Err(_) => {
141+
tracing::warn!("Protobuf decode failed, treating state as encrypted");
142+
Self::decrypt(&raw_bytes, &self.encryption_key)?
143+
}
144+
}
145+
} else {
146+
Self::decrypt(&raw_bytes, &self.encryption_key)?
147+
};
148+
149+
let request = SetResolverStateRequest::decode(decrypted_bytes.as_slice()).map_err(|e| {
150+
Error::StateParse(format!(
151+
"Failed to decode decrypted SetResolverStateRequest: {}",
152+
e
153+
))
107154
})?;
108155

109-
// Parse the inner ResolverState
110156
let state_pb = ResolverStatePb::decode(request.state)
111157
.map_err(|e| Error::StateParse(format!("Failed to decode ResolverState: {}", e)))?;
112158

113-
// Convert to ResolverState
114159
let state = ResolverState::from_proto(state_pb, &request.account_id, self.sdk.clone())
115160
.map_err(|e| Error::StateParse(format!("Failed to create ResolverState: {:?}", e)))?;
116161

117162
Ok(Some((state, request.account_id)))
118163
}
119164

165+
/// Decrypt AES-256-GCM encrypted state (Tink NO_PREFIX format).
166+
fn decrypt(data: &[u8], key: &Option<Vec<u8>>) -> Result<Vec<u8>> {
167+
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit, Nonce};
168+
169+
let key_bytes = key.as_ref().ok_or_else(|| {
170+
Error::StateParse(
171+
"Resolver state is encrypted but no encryption_key was provided. \
172+
Set the encryption key for this client credential."
173+
.to_string(),
174+
)
175+
})?;
176+
177+
if data.len() < 12 {
178+
return Err(Error::StateParse(
179+
"Encrypted state too short (missing nonce)".to_string(),
180+
));
181+
}
182+
183+
let cipher = Aes256Gcm::new_from_slice(key_bytes)
184+
.map_err(|e| Error::StateParse(format!("Invalid encryption key: {}", e)))?;
185+
let nonce = Nonce::from_slice(&data[..12]);
186+
cipher
187+
.decrypt(nonce, &data[12..])
188+
.map_err(|_| {
189+
Error::StateParse(
190+
"Failed to decrypt resolver state: invalid key or corrupted data".to_string(),
191+
)
192+
})
193+
}
194+
120195
/// Get the client secret.
121196
pub fn client_secret(&self) -> &str {
122197
&self.client_secret
@@ -171,6 +246,16 @@ impl Default for SharedState {
171246
mod tests {
172247
use super::*;
173248
use crate::test_utils::{create_minimal_state, create_state_with_flag};
249+
use std::path::PathBuf;
250+
251+
fn data_dir() -> PathBuf {
252+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
253+
.parent()
254+
.unwrap()
255+
.parent()
256+
.unwrap()
257+
.join("data")
258+
}
174259

175260
#[test]
176261
fn test_hash_client_secret() {
@@ -259,4 +344,37 @@ mod tests {
259344
Some("custom-account-id".to_string())
260345
);
261346
}
347+
348+
#[test]
349+
fn test_decrypt_encrypted_state() {
350+
let encrypted = std::fs::read(data_dir().join("resolver_state_encrypted.pb")).unwrap();
351+
let hex_key =
352+
std::fs::read_to_string(data_dir().join("encryption_key_test.hex")).unwrap();
353+
let key = Some(hex::decode(hex_key.trim()).unwrap());
354+
355+
let decrypted = StateFetcher::decrypt(&encrypted, &key).unwrap();
356+
let request = SetResolverStateRequest::decode(decrypted.as_slice()).unwrap();
357+
assert_eq!(request.account_id, "confidence-test");
358+
359+
let state_pb = ResolverStatePb::decode(request.state).unwrap();
360+
let state = ResolverState::from_proto(state_pb, &request.account_id, None).unwrap();
361+
assert!(!state.flags.is_empty());
362+
}
363+
364+
#[test]
365+
fn test_decrypt_rejects_wrong_key() {
366+
use aes_gcm::{Aes256Gcm, KeyInit, aead::OsRng};
367+
let encrypted = std::fs::read(data_dir().join("resolver_state_encrypted.pb")).unwrap();
368+
let wrong_key = Aes256Gcm::generate_key(OsRng).to_vec();
369+
let result = StateFetcher::decrypt(&encrypted, &Some(wrong_key));
370+
assert!(result.is_err());
371+
}
372+
373+
#[test]
374+
fn test_decrypt_rejects_missing_key() {
375+
let encrypted = std::fs::read(data_dir().join("resolver_state_encrypted.pb")).unwrap();
376+
377+
let result = StateFetcher::decrypt(&encrypted, &None);
378+
assert!(result.is_err());
379+
}
262380
}

0 commit comments

Comments
 (0)