Skip to content

Commit 29cbe3d

Browse files
committed
updated cdn to have encrypted manifest
1 parent 7b3b3ed commit 29cbe3d

1 file changed

Lines changed: 143 additions & 5 deletions

File tree

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

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import 'package:yaml/yaml.dart';
1616
import 'package:brotli/brotli.dart';
1717
import 'package:shared_preferences/shared_preferences.dart';
1818
import '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
2123
class 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

Comments
 (0)