Skip to content

Commit 4266918

Browse files
committed
feat(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management
- Added support for loading environment variables from asset files, including `.env.secrets` and `.env`. - Implemented methods to manage runtime secrets and ensure compatibility with existing dotenv initialization. - Enhanced the manifest decoding process to extract secrets from the artifacts section. - Introduced utility functions for base64 and hex decoding to support secret parsing.
1 parent 7b3b3ed commit 4266918

1 file changed

Lines changed: 266 additions & 9 deletions

File tree

modules/ensemble/lib/framework/definition_providers/cdn_provider.dart

Lines changed: 266 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:ensemble/ensemble.dart';
55
import 'package:ensemble/framework/bindings.dart';
66
import 'package:ensemble/framework/definition_providers/provider.dart';
77
import 'package:ensemble/framework/error_handling.dart';
8+
import 'package:encrypt/encrypt.dart' as enc;
89
import 'package:ensemble/framework/i18n_loader.dart';
910
import 'package:ensemble/framework/widget/screen.dart';
1011
import 'package:ensemble/util/utils.dart';
@@ -16,6 +17,9 @@ import 'package:yaml/yaml.dart';
1617
import 'package:brotli/brotli.dart';
1718
import 'package:shared_preferences/shared_preferences.dart';
1819
import 'package:flutter_dotenv/flutter_dotenv.dart';
20+
import 'package:flutter/services.dart';
21+
22+
import 'package:ensemble/framework/dotenv_bundle.dart';
1923

2024
/// DefinitionProvider that reads the app manifest from CDN
2125
class CdnDefinitionProvider extends DefinitionProvider {
@@ -50,8 +54,20 @@ class CdnDefinitionProvider extends DefinitionProvider {
5054

5155
static const String _i18nPrefix = 'i18n_';
5256

57+
// Cached env entries read from assets (so we can read `.env.secrets` even if
58+
// dotenv was already initialized elsewhere, often from `.env`).
59+
Map<String, String>? _assetEnv;
60+
61+
// Secrets hydrated from encrypted-manifest.json (artifacts.secrets).
62+
final Map<String, String> _runtimeSecrets = {};
63+
5364
@override
5465
Future<DefinitionProvider> init() async {
66+
// Ensure we can decide which manifest to fetch based on
67+
// ENSEMBLE_ENCRYPTION_KEY, regardless of whether dotenv was already
68+
// initialized (e.g. from `.env`).
69+
await _initEnvFromAssets();
70+
5571
await _loadCachedState();
5672
if (_artifactCache.isNotEmpty) {
5773
unawaited(_refreshIfStale());
@@ -118,10 +134,29 @@ class CdnDefinitionProvider extends DefinitionProvider {
118134

119135
@override
120136
Map<String, String> getSecrets() {
137+
final out = <String, String>{};
138+
out.addAll(_runtimeSecrets);
121139
if (dotenv.isInitialized) {
122-
return Map<String, String>.from(dotenv.env);
140+
out.addAll(dotenv.env);
123141
}
124-
return {};
142+
return out;
143+
}
144+
145+
void _applySecretsFromRoot(Map<String, dynamic> root) {
146+
final artifacts = _asMap(root['artifacts']);
147+
if (artifacts == null) return;
148+
149+
// Per requirement: artifacts.secrets is a flat key/value mapping.
150+
final rawSecrets = _asMap(artifacts['secrets']);
151+
if (rawSecrets == null || rawSecrets.isEmpty) return;
152+
153+
rawSecrets.forEach((k, v) {
154+
_runtimeSecrets[k.toString()] = v?.toString() ?? '';
155+
});
156+
157+
// Make secrets visible to `_getSecret()` and any legacy dotenv lookups.
158+
_assetEnv ??= <String, String>{};
159+
_assetEnv!.addAll(_runtimeSecrets);
125160
}
126161

127162
@override
@@ -176,7 +211,8 @@ class CdnDefinitionProvider extends DefinitionProvider {
176211

177212
if (cachedManifest != null && cachedManifest.isNotEmpty) {
178213
try {
179-
final root = jsonDecode(cachedManifest) as Map<String, dynamic>;
214+
final root = _decodeManifestRoot(cachedManifest);
215+
_applySecretsFromRoot(root);
180216
_rebuildFromRoot(root);
181217
} catch (e) {
182218
// Clear invalid cache
@@ -216,6 +252,197 @@ class CdnDefinitionProvider extends DefinitionProvider {
216252
// Networking / manifest loading
217253
// --------------------------------------------------------
218254

255+
Future<void> _initEnvFromAssets() async {
256+
if (_assetEnv != null) return;
257+
258+
final merged = <String, String>{};
259+
260+
Future<void> tryLoad(String assetPath) async {
261+
try {
262+
final content = await rootBundle.loadString(assetPath);
263+
merged.addAll(parseDotEnvBundleContent(content));
264+
} catch (_) {
265+
// ignore missing/invalid asset
266+
}
267+
}
268+
269+
// Mirror SecretsStore.initialize() intent for dotenv-based secrets.
270+
await tryLoad('ensemble/.env.secrets');
271+
await tryLoad('.env.secrets');
272+
await tryLoad('.env');
273+
274+
// Also merge whatever dotenv already has (if another part of the app loaded it).
275+
if (dotenv.isInitialized) {
276+
merged.addAll(dotenv.env);
277+
}
278+
279+
_assetEnv = merged;
280+
}
281+
282+
bool _hasEncryptionKey() {
283+
final key = (_assetEnv ?? const {})['ENSEMBLE_ENCRYPTION_KEY'] ??
284+
dotenv.env['ENSEMBLE_ENCRYPTION_KEY'];
285+
return key != null && key.trim().isNotEmpty;
286+
}
287+
288+
String? _getSecret(String name) {
289+
return (_assetEnv ?? const {})[name] ?? dotenv.env[name];
290+
}
291+
292+
static Uint8List _b64UrlDecode(String input) {
293+
final normalized = input.trim().replaceAll('-', '+').replaceAll('_', '/');
294+
final pad = (4 - (normalized.length % 4)) % 4;
295+
return base64.decode(normalized + ('=' * pad));
296+
}
297+
298+
static Uint8List _b64Decode(String input) => base64.decode(input.trim());
299+
300+
static Uint8List _b64AnyDecode(String input) {
301+
final trimmed = input.trim();
302+
// prefer url-safe decode first since it works for standard base64 too
303+
try {
304+
return _b64UrlDecode(trimmed);
305+
} catch (_) {
306+
return _b64Decode(trimmed);
307+
}
308+
}
309+
310+
static Map<String, dynamic> _decodeManifestRoot(String jsonString) {
311+
final decoded = jsonDecode(jsonString);
312+
if (decoded is! Map) {
313+
throw const FormatException('Manifest root is not a JSON object.');
314+
}
315+
316+
// Expected shape is: { artifacts: { ... } }
317+
if (decoded.containsKey('artifacts')) {
318+
return Map<String, dynamic>.from(decoded);
319+
}
320+
321+
// Some endpoints wrap the real manifest under `manifest`.
322+
final manifest = decoded['manifest'];
323+
if (manifest is Map && manifest.containsKey('artifacts')) {
324+
return Map<String, dynamic>.from(manifest);
325+
}
326+
if (manifest is String && manifest.trim().isNotEmpty) {
327+
final inner = jsonDecode(manifest);
328+
if (inner is Map && inner.containsKey('artifacts')) {
329+
return Map<String, dynamic>.from(inner);
330+
}
331+
}
332+
333+
// Fall back to original map (better error messages downstream).
334+
return Map<String, dynamic>.from(decoded);
335+
}
336+
337+
static Uint8List _hexDecode(String input) {
338+
final s = input.trim();
339+
if (s.length.isOdd) {
340+
throw const FormatException('Odd-length hex string.');
341+
}
342+
final out = Uint8List(s.length ~/ 2);
343+
for (var i = 0; i < s.length; i += 2) {
344+
final byteStr = s.substring(i, i + 2);
345+
out[i ~/ 2] = int.parse(byteStr, radix: 16);
346+
}
347+
return out;
348+
}
349+
350+
static enc.Key _parseAesKey(String keyStr) {
351+
final trimmed = keyStr.trim();
352+
if (trimmed.isEmpty) {
353+
throw const FormatException('Empty key.');
354+
}
355+
356+
// Accept common encodings:
357+
// - hex (32/48/64 chars => 16/24/32 bytes)
358+
// - base64/base64url (decodes to 16/24/32 bytes)
359+
// - raw UTF-8 (16/24/32 bytes)
360+
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(trimmed);
361+
if (isHex &&
362+
(trimmed.length == 32 ||
363+
trimmed.length == 48 ||
364+
trimmed.length == 64)) {
365+
final bytes = _hexDecode(trimmed);
366+
return enc.Key(bytes);
367+
}
368+
369+
try {
370+
final bytes = _b64Decode(trimmed);
371+
if (bytes.length == 16 || bytes.length == 24 || bytes.length == 32) {
372+
return enc.Key(bytes);
373+
}
374+
} catch (_) {
375+
// ignore - fall through
376+
}
377+
378+
try {
379+
final bytes = _b64UrlDecode(trimmed);
380+
if (bytes.length == 16 || bytes.length == 24 || bytes.length == 32) {
381+
return enc.Key(bytes);
382+
}
383+
} catch (_) {
384+
// ignore - fall through
385+
}
386+
387+
final utf8Bytes = utf8.encode(trimmed);
388+
if (utf8Bytes.length == 16 ||
389+
utf8Bytes.length == 24 ||
390+
utf8Bytes.length == 32) {
391+
return enc.Key(Uint8List.fromList(utf8Bytes));
392+
}
393+
394+
throw FormatException(
395+
'Invalid AES key length (${utf8Bytes.length} bytes). Provide a 16/24/32-byte key '
396+
'(AES-128/192/256), or hex (32/48/64 chars), or base64 that decodes to 16/24/32 bytes.',
397+
);
398+
}
399+
400+
/// Decrypt encrypted-manifest envelope into manifest JSON string.
401+
String _decryptEncryptedManifestEnvelope(String envelopeJson) {
402+
final keyStr = _getSecret('ENSEMBLE_ENCRYPTION_KEY');
403+
if (keyStr == null || keyStr.trim().isEmpty) {
404+
throw ConfigError(
405+
'Encrypted manifest requested but ENSEMBLE_ENCRYPTION_KEY is missing.');
406+
}
407+
final decoded = jsonDecode(envelopeJson);
408+
if (decoded is! Map) {
409+
throw ConfigError('Invalid encrypted manifest payload.');
410+
}
411+
412+
final ivStr = decoded['iv']?.toString();
413+
final tagStr = decoded['tag']?.toString();
414+
final cipherStr = decoded['ciphertext']?.toString();
415+
416+
if (ivStr == null || tagStr == null || cipherStr == null) {
417+
throw ConfigError('Encrypted manifest payload is missing fields.');
418+
}
419+
420+
final ivBytes = _b64AnyDecode(ivStr);
421+
final tagBytes = _b64AnyDecode(tagStr);
422+
final cipherBytes = _b64AnyDecode(cipherStr);
423+
final combined = Uint8List.fromList([...cipherBytes, ...tagBytes]);
424+
425+
try {
426+
final key = _parseAesKey(keyStr);
427+
final encrypter = enc.Encrypter(enc.AES(key, mode: enc.AESMode.gcm));
428+
final decryptedBytes = encrypter.decryptBytes(
429+
enc.Encrypted(combined),
430+
iv: enc.IV(ivBytes),
431+
);
432+
433+
try {
434+
return utf8.decode(decryptedBytes);
435+
} on FormatException {
436+
// Some deployments compress the plaintext manifest before encrypting.
437+
// Try brotli as a fallback before surfacing an error.
438+
final decompressed = brotliDecode(decryptedBytes);
439+
return utf8.decode(decompressed);
440+
}
441+
} catch (e) {
442+
throw ConfigError('Failed to decrypt encrypted manifest: $e');
443+
}
444+
}
445+
219446
/// Check for updates and update cache if available
220447
/// Sets _hasPendingUpdate flag if updates were fetched
221448
Future<void> _refreshIfStale() async {
@@ -232,8 +459,9 @@ class CdnDefinitionProvider extends DefinitionProvider {
232459
if (jsonString == null) return;
233460

234461
final newEtag = fetched['etag'] as String?;
235-
final root = jsonDecode(jsonString) as Map<String, dynamic>;
462+
final root = _decodeManifestRoot(jsonString);
236463

464+
_applySecretsFromRoot(root);
237465
_rebuildFromRoot(root);
238466
await _refreshTranslationsAtRuntime();
239467
_etag = newEtag ?? _etag;
@@ -281,7 +509,8 @@ class CdnDefinitionProvider extends DefinitionProvider {
281509

282510
_etag = fetched['etag'] as String?;
283511

284-
final root = jsonDecode(jsonString) as Map<String, dynamic>;
512+
final root = _decodeManifestRoot(jsonString);
513+
_applySecretsFromRoot(root);
285514
_rebuildFromRoot(root);
286515

287516
// Save to persistent cache
@@ -328,22 +557,50 @@ class CdnDefinitionProvider extends DefinitionProvider {
328557
}
329558

330559
Future<Map<String, Object>?> _fetchManifest({String? ifNoneMatch}) async {
331-
final uri = Uri.parse('$baseUrl/$appId/manifest.json');
560+
final shouldUseEncrypted = _hasEncryptionKey();
561+
final encryptedUri = Uri.parse('$baseUrl/$appId/encrypted-manifest.json');
562+
final plainUri = Uri.parse('$baseUrl/$appId/manifest.json');
332563

333564
final headers = <String, String>{};
334565
if (ifNoneMatch != null && ifNoneMatch.isNotEmpty) {
335566
headers['If-None-Match'] = ifNoneMatch;
336567
}
337568

338-
final resp = await http.get(uri, headers: headers);
569+
http.Response resp;
570+
if (shouldUseEncrypted) {
571+
final encryptedHeaders = Map<String, String>.from(headers);
572+
final manifestKey = _getSecret('ENSEMBLE_MANIFEST_KEY');
573+
if (manifestKey != null && manifestKey.trim().isNotEmpty) {
574+
encryptedHeaders['x-manifest-key'] = manifestKey.trim();
575+
}
576+
577+
resp = await http.get(encryptedUri, headers: encryptedHeaders);
578+
// If encrypted manifest doesn't exist for this app, fall back to plain.
579+
if (resp.statusCode == 404) {
580+
resp = await http.get(plainUri, headers: headers);
581+
}
582+
} else {
583+
resp = await http.get(plainUri, headers: headers);
584+
}
585+
339586
if (resp.statusCode == 304) return null;
340587
if (resp.statusCode != 200 || resp.bodyBytes.isEmpty) {
341588
throw ConfigError(
342589
"Failed to fetch manifest from CDN. Please check your appId and make sure to sync app to CDN.");
343590
}
344591

345-
final jsonString = _decodePossiblyBrotli(resp);
346-
if (jsonString == null || jsonString.isEmpty) return null;
592+
// Decode transport-level brotli first (Content-Encoding: br).
593+
// This applies to BOTH plain and encrypted-manifest endpoints.
594+
final decodedBody = _decodePossiblyBrotli(resp);
595+
if (decodedBody == null || decodedBody.isEmpty) return null;
596+
597+
String jsonString = decodedBody;
598+
599+
// If we fetched the encrypted-manifest endpoint successfully, decrypt it.
600+
if (shouldUseEncrypted && resp.request?.url == encryptedUri) {
601+
jsonString = _decryptEncryptedManifestEnvelope(decodedBody);
602+
}
603+
if (jsonString.isEmpty) return null;
347604

348605
final etag = resp.headers['etag'] ?? resp.headers['ETag'];
349606
return {'json': jsonString, 'etag': etag ?? ''};

0 commit comments

Comments
 (0)