@@ -16,6 +16,8 @@ import 'package:yaml/yaml.dart';
1616import 'package:brotli/brotli.dart' ;
1717import 'package:shared_preferences/shared_preferences.dart' ;
1818import 'package:flutter_dotenv/flutter_dotenv.dart' ;
19+ import 'package:encrypt/encrypt.dart' as encrypt;
20+ import 'package:convert/convert.dart' ;
1921
2022/// DefinitionProvider that reads the app manifest from CDN
2123class CdnDefinitionProvider extends DefinitionProvider {
@@ -45,13 +47,28 @@ class CdnDefinitionProvider extends DefinitionProvider {
4547 // Background update tracking
4648 bool _hasPendingUpdate = false ;
4749
50+ // Encryption support for CDN secrets
51+ Uint8List ? _encryptionKey;
52+ String ? _manifestKey;
53+ Map <String , String > _cdnSecrets = {};
54+
4855 // Persistent cache key
4956 String get _artifactCacheKey => 'cdn_provider_state_$appId ' ;
5057
5158 static const String _i18nPrefix = 'i18n_' ;
5259
5360 @override
5461 Future <DefinitionProvider > init () async {
62+ // Load encryption keys from dotenv (if available)
63+ if (dotenv.isInitialized) {
64+ _encryptionKey = _parseEncryptionKey (dotenv.env['ENSEMBLE_ENCRYPTION_KEY' ]);
65+ _manifestKey = dotenv.env['ENSEMBLE_MANIFEST_KEY' ];
66+
67+ if (_encryptionKey != null && kDebugMode) {
68+ debugPrint ('CdnProvider: Encryption key loaded, will fetch encrypted manifest' );
69+ }
70+ }
71+
5572 await _loadCachedState ();
5673 if (_artifactCache.isNotEmpty) {
5774 unawaited (_refreshIfStale ());
@@ -118,10 +135,17 @@ class CdnDefinitionProvider extends DefinitionProvider {
118135
119136 @override
120137 Map <String , String > getSecrets () {
138+ final secrets = < String , String > {};
139+
140+ // Add CDN secrets first (lower priority)
141+ secrets.addAll (_cdnSecrets);
142+
143+ // Add dotenv secrets (higher priority - can override CDN)
121144 if (dotenv.isInitialized) {
122- return Map <String , String >.from (dotenv.env);
145+ secrets. addAll ( Map <String , String >.from (dotenv.env) );
123146 }
124- return {};
147+
148+ return secrets;
125149 }
126150
127151 @override
@@ -328,15 +352,30 @@ class CdnDefinitionProvider extends DefinitionProvider {
328352 }
329353
330354 Future <Map <String , Object >?> _fetchManifest ({String ? ifNoneMatch}) async {
331- final uri = Uri .parse ('$baseUrl /$appId /manifest.json' );
355+ // Choose endpoint based on encryption key availability
356+ final useEncrypted = _encryptionKey != null ;
357+ final filename = useEncrypted ? 'encrypted-manifest.json' : 'manifest.json' ;
358+ final uri = Uri .parse ('$baseUrl /$appId /$filename ' );
332359
333360 final headers = < String , String > {};
334361 if (ifNoneMatch != null && ifNoneMatch.isNotEmpty) {
335362 headers['If-None-Match' ] = ifNoneMatch;
336363 }
337364
365+ // Add manifest key header for WAF access control (if available)
366+ if (_manifestKey != null && _manifestKey! .isNotEmpty) {
367+ headers['x-manifest-key' ] = _manifestKey! ;
368+ }
369+
338370 final resp = await http.get (uri, headers: headers);
339371 if (resp.statusCode == 304 ) return null ;
372+
373+ // Handle WAF denial (403 Forbidden)
374+ if (resp.statusCode == 403 ) {
375+ throw ConfigError (
376+ "Access denied to encrypted manifest. Please check your ENSEMBLE_MANIFEST_KEY." );
377+ }
378+
340379 if (resp.statusCode != 200 || resp.bodyBytes.isEmpty) {
341380 throw ConfigError (
342381 "Failed to fetch manifest from CDN. Please check your appId and make sure to sync app to CDN." );
@@ -345,8 +384,20 @@ class CdnDefinitionProvider extends DefinitionProvider {
345384 final jsonString = _decodePossiblyBrotli (resp);
346385 if (jsonString == null || jsonString.isEmpty) return null ;
347386
387+ // If encrypted, decrypt to get the actual manifest JSON
388+ String manifestJson;
389+ if (useEncrypted) {
390+ final decrypted = _decryptManifest (jsonString);
391+ if (decrypted == null ) {
392+ throw ConfigError ("Failed to decrypt manifest. Check your ENSEMBLE_ENCRYPTION_KEY." );
393+ }
394+ manifestJson = jsonEncode (decrypted);
395+ } else {
396+ manifestJson = jsonString;
397+ }
398+
348399 final etag = resp.headers['etag' ] ?? resp.headers['ETag' ];
349- return {'json' : jsonString , 'etag' : etag ?? '' };
400+ return {'json' : manifestJson , 'etag' : etag ?? '' };
350401 }
351402
352403 String ? _decodePossiblyBrotli (http.Response resp) {
@@ -377,6 +428,7 @@ class CdnDefinitionProvider extends DefinitionProvider {
377428 _themeMapping = null ;
378429 _defaultLocale = null ;
379430 _appConfig = null ;
431+ _cdnSecrets = {};
380432
381433 final artifacts = _asMap (root['artifacts' ]);
382434 if (artifacts == null ) return ;
@@ -400,7 +452,16 @@ class CdnDefinitionProvider extends DefinitionProvider {
400452 artifacts['widgets' ], artifacts['scripts' ], artifacts['actions' ]);
401453 _parseTranslations (artifacts['translations' ]);
402454
403- // 3) finalize AppConfig
455+ // 3) secrets (from encrypted manifest)
456+ final secretsMap = _asMap (artifacts['secrets' ]);
457+ if (secretsMap != null ) {
458+ _cdnSecrets = secretsMap.map ((k, v) => MapEntry (k, v? .toString () ?? '' ));
459+ if (kDebugMode && _cdnSecrets.isNotEmpty) {
460+ debugPrint ('CdnProvider: Loaded ${_cdnSecrets .length } secrets from CDN' );
461+ }
462+ }
463+
464+ // 4) finalize AppConfig
404465 if (envVars.isNotEmpty || baseUrl != null || useBrowserUrl != null ) {
405466 _appConfig = UserAppConfig (
406467 baseUrl: baseUrl,
@@ -546,6 +607,83 @@ class CdnDefinitionProvider extends DefinitionProvider {
546607 static bool _isIncomingNewer (int ? incoming, int ? current) =>
547608 incoming != null && (current == null || incoming > current);
548609
610+ // --------------------------------------------------------
611+ // Encryption helpers (for encrypted manifest support)
612+ // --------------------------------------------------------
613+
614+ /// Parses a 256-bit encryption key from various formats.
615+ /// Supports: 64-char hex, base64 (32 bytes), or 32-byte UTF-8 string.
616+ /// Returns null if the key is invalid or not provided.
617+ static Uint8List ? _parseEncryptionKey (String ? keyString) {
618+ if (keyString == null || keyString.isEmpty) return null ;
619+
620+ // Try hex (64 chars = 32 bytes)
621+ if (RegExp (r'^[0-9a-fA-F]{64}$' ).hasMatch (keyString)) {
622+ try {
623+ return Uint8List .fromList (hex.decode (keyString));
624+ } catch (_) {}
625+ }
626+
627+ // Try base64 (decodes to 32 bytes)
628+ try {
629+ final decoded = base64.decode (keyString);
630+ if (decoded.length == 32 ) return Uint8List .fromList (decoded);
631+ } catch (_) {}
632+
633+ // Try UTF-8 (exactly 32 bytes)
634+ final utf8Bytes = utf8.encode (keyString);
635+ if (utf8Bytes.length == 32 ) return Uint8List .fromList (utf8Bytes);
636+
637+ debugPrint ('CdnProvider: Invalid encryption key format. '
638+ 'Expected 64-char hex, base64 (32 bytes), or 32-byte UTF-8 string.' );
639+ return null ;
640+ }
641+
642+ /// Decrypts the encrypted manifest envelope and returns the inner manifest.
643+ /// The envelope format: { v, alg, comp, iv, tag, ciphertext }
644+ /// Returns the manifest map (same structure as public manifest, but with secrets).
645+ Map <String , dynamic >? _decryptManifest (String encryptedJson) {
646+ if (_encryptionKey == null ) return null ;
647+
648+ try {
649+ final envelope = jsonDecode (encryptedJson) as Map <String , dynamic >;
650+
651+ // Validate format
652+ if (envelope['v' ] != 1 || envelope['alg' ] != 'AES-256-GCM' ) {
653+ throw ConfigError ('Unsupported encrypted manifest format: '
654+ 'v=${envelope ['v' ]}, alg=${envelope ['alg' ]}' );
655+ }
656+
657+ // Decode components
658+ final iv = encrypt.IV .fromBase64 (envelope['iv' ] as String );
659+ final tag = base64.decode (envelope['tag' ] as String );
660+ final ciphertext = base64.decode (envelope['ciphertext' ] as String );
661+
662+ // Combine ciphertext + tag (Dart encrypt package expects tag appended)
663+ final combined = Uint8List .fromList ([...ciphertext, ...tag]);
664+
665+ // Decrypt
666+ final key = encrypt.Key (_encryptionKey! );
667+ final encrypter = encrypt.Encrypter (encrypt.AES (key, mode: encrypt.AESMode .gcm));
668+ final decryptedBytes = encrypter.decryptBytes (encrypt.Encrypted (combined), iv: iv);
669+
670+ // Decompress if needed (comp: "br" means Brotli compressed)
671+ List <int > plaintext;
672+ if (envelope['comp' ] == 'br' ) {
673+ plaintext = brotliDecode (Uint8List .fromList (decryptedBytes));
674+ } else {
675+ plaintext = decryptedBytes;
676+ }
677+
678+ // Parse JSON and unwrap from 'manifest' key
679+ final wrapper = jsonDecode (utf8.decode (plaintext)) as Map <String , dynamic >;
680+ return wrapper['manifest' ] as Map <String , dynamic >? ;
681+ } catch (e) {
682+ debugPrint ('CdnProvider: Failed to decrypt manifest: $e ' );
683+ rethrow ;
684+ }
685+ }
686+
549687 static Map <String , dynamic >? _asMap (dynamic value) {
550688 if (value is Map <String , dynamic >) return value;
551689 if (value is Map ) return Map <String , dynamic >.from (value);
0 commit comments