@@ -5,6 +5,7 @@ import 'package:ensemble/ensemble.dart';
55import 'package:ensemble/framework/bindings.dart' ;
66import 'package:ensemble/framework/definition_providers/provider.dart' ;
77import 'package:ensemble/framework/error_handling.dart' ;
8+ import 'package:encrypt/encrypt.dart' as enc;
89import 'package:ensemble/framework/i18n_loader.dart' ;
910import 'package:ensemble/framework/widget/screen.dart' ;
1011import 'package:ensemble/util/utils.dart' ;
@@ -16,6 +17,9 @@ import 'package:yaml/yaml.dart';
1617import 'package:brotli/brotli.dart' ;
1718import 'package:shared_preferences/shared_preferences.dart' ;
1819import '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
2125class 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