diff --git a/packages/contracts/lib/src/encryption/encrypter.dart b/packages/contracts/lib/src/encryption/encrypter.dart index e0310fa..129834b 100644 --- a/packages/contracts/lib/src/encryption/encrypter.dart +++ b/packages/contracts/lib/src/encryption/encrypter.dart @@ -1,5 +1,5 @@ /// Interface for encryption. -abstract class Encrypter { +abstract class EncrypterContract { /// Encrypt the given value. /// /// @throws EncryptException diff --git a/packages/encryption/.gitignore b/packages/encryption/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/encryption/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/encryption/CHANGELOG.md b/packages/encryption/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/encryption/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/encryption/LICENSE.md b/packages/encryption/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/encryption/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/encryption/README.md b/packages/encryption/README.md new file mode 100644 index 0000000..37b5c36 --- /dev/null +++ b/packages/encryption/README.md @@ -0,0 +1,105 @@ +# Platform Encryption + +A robust and flexible encryption package for the Protevus platform, implementing Laravel-inspired encryption functionality in Dart. + +## Features + +- Support for multiple cipher types (AES-128-CBC, AES-256-CBC, AES-128-GCM, AES-256-GCM) +- Encryption and decryption of both strings and objects +- Key management with support for previous keys +- Proper error handling with EncryptException and DecryptException + +## Installation + +Add this package to your `pubspec.yaml`: + +```yaml +dependencies: + platform_encryption: ^1.0.0 +``` + +Then run: + +``` +dart pub get +``` + +## Usage + +Here's a basic example of how to use the Encrypter class: + +```dart +import 'package:platform_encryption/platform_encryption.dart'; + +void main() { + // Generate a key for AES-256-CBC + final key = Encrypter.generateKey('aes-256-cbc'); + + // Create an Encrypter instance + final encrypter = Encrypter(key, cipher: 'aes-256-cbc'); + + // Encrypt a string + const originalString = 'Hello, Protevus Platform!'; + final encryptedString = encrypter.encryptString(originalString); + + // Decrypt the string + final decryptedString = encrypter.decryptString(encryptedString); + + print('Original: $originalString'); + print('Encrypted: $encryptedString'); + print('Decrypted: $decryptedString'); +} +``` + +### Encrypting and Decrypting Objects + +You can also encrypt and decrypt objects: + +```dart +final originalObject = {'username': 'john_doe', 'email': 'john@example.com'}; +final encryptedObject = encrypter.encrypt(originalObject); +final decryptedObject = encrypter.decrypt(encryptedObject); +``` + +### Using Previous Keys + +To support key rotation, you can provide previous keys when creating an Encrypter instance: + +```dart +final oldKey = Encrypter.generateKey('aes-256-cbc'); +final newKey = Encrypter.generateKey('aes-256-cbc'); + +final encrypter = Encrypter(newKey, cipher: 'aes-256-cbc', previousKeys: [oldKey]); +``` + +This allows the Encrypter to decrypt messages that were encrypted with the old key. + +### Error Handling + +The package throws `EncryptException` and `DecryptException` for encryption and decryption errors respectively: + +```dart +try { + encrypter.encrypt(null); +} on EncryptException catch (e) { + print('Encryption failed: $e'); +} + +try { + encrypter.decrypt('invalid_payload'); +} on DecryptException catch (e) { + print('Decryption failed: $e'); +} +``` + +## API Reference + +For detailed API documentation, please refer to the [API Reference](link-to-api-docs). + +## Contributing + +Contributions are welcome! Please read our [contributing guidelines](link-to-contributing-guidelines) before submitting pull requests. + +## License + +This project is licensed under the [MIT License](link-to-license). diff --git a/packages/encryption/analysis_options.yaml b/packages/encryption/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/encryption/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/encryption/example/encryption_example.dart b/packages/encryption/example/encryption_example.dart new file mode 100644 index 0000000..4a964bc --- /dev/null +++ b/packages/encryption/example/encryption_example.dart @@ -0,0 +1,67 @@ +import 'package:platform_encryption/platform_encryption.dart'; +import 'package:platform_contracts/contracts.dart'; + +void main() { + // Generate a key for AES-256-CBC + final key = Encrypter.generateKey('aes-256-cbc'); + print('Generated key: $key'); + + // Create an Encrypter instance + final encrypter = Encrypter(key, cipher: 'aes-256-cbc'); + + // Example 1: Encrypting and decrypting a string + const originalString = 'Hello, Protevus Platform!'; + print('\nExample 1: String encryption'); + print('Original: $originalString'); + + final encryptedString = encrypter.encryptString(originalString); + print('Encrypted: $encryptedString'); + + final decryptedString = encrypter.decryptString(encryptedString); + print('Decrypted: $decryptedString'); + + // Example 2: Encrypting and decrypting an object + final originalObject = { + 'username': 'john_doe', + 'email': 'john@example.com', + 'age': 30, + }; + print('\nExample 2: Object encryption'); + print('Original: $originalObject'); + + final encryptedObject = encrypter.encrypt(originalObject); + print('Encrypted: $encryptedObject'); + + final decryptedObject = encrypter.decrypt(encryptedObject); + print('Decrypted: $decryptedObject'); + + // Example 3: Handling encryption exceptions + print('\nExample 3: Handling exceptions'); + try { + encrypter.encrypt(null); + } on EncryptException catch (e) { + print('Caught EncryptException: $e'); + } + + // Example 4: Handling decryption exceptions + try { + encrypter.decrypt('invalid_payload'); + } on DecryptException catch (e) { + print('Caught DecryptException: $e'); + } + + // Example 5: Using previous keys + print('\nExample 5: Using previous keys'); + final oldKey = Encrypter.generateKey('aes-256-cbc'); + final newKey = Encrypter.generateKey('aes-256-cbc'); + + final oldEncrypter = Encrypter(oldKey, cipher: 'aes-256-cbc'); + final encryptedWithOldKey = oldEncrypter.encryptString('Secret message'); + print('Encrypted with old key: $encryptedWithOldKey'); + + final newEncrypter = + Encrypter(newKey, cipher: 'aes-256-cbc', previousKeys: [oldKey]); + final decryptedWithNewEncrypter = + newEncrypter.decryptString(encryptedWithOldKey); + print('Decrypted with new encrypter: $decryptedWithNewEncrypter'); +} diff --git a/packages/encryption/lib/platform_encryption.dart b/packages/encryption/lib/platform_encryption.dart new file mode 100644 index 0000000..3b4628c --- /dev/null +++ b/packages/encryption/lib/platform_encryption.dart @@ -0,0 +1,3 @@ +library platform_encryption; + +export 'src/encrypter.dart'; diff --git a/packages/encryption/lib/src/encrypter.dart b/packages/encryption/lib/src/encrypter.dart new file mode 100644 index 0000000..4d0896a --- /dev/null +++ b/packages/encryption/lib/src/encrypter.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart' as encryption; +import 'package:platform_contracts/contracts.dart'; + +class Encrypter implements EncrypterContract, StringEncrypter { + final String _key; + final String _cipher; + final List _previousKeys; + + static const Map> supportedCiphers = { + 'aes-128-cbc': {'size': 16, 'aead': false}, + 'aes-256-cbc': {'size': 32, 'aead': false}, + 'aes-128-gcm': {'size': 16, 'aead': true}, + 'aes-256-gcm': {'size': 32, 'aead': true}, + }; + + Encrypter(this._key, + {String cipher = 'aes-256-cbc', List previousKeys = const []}) + : _cipher = cipher, + _previousKeys = previousKeys { + if (!isSupported(_key, _cipher)) { + final ciphers = supportedCiphers.keys.join(', '); + throw ArgumentError( + 'Unsupported cipher or incorrect key length. Supported ciphers are: $ciphers.'); + } + } + + static bool isSupported(String key, String cipher) { + final lowerCipher = cipher.toLowerCase(); + if (!supportedCiphers.containsKey(lowerCipher)) { + return false; + } + return base64.decode(key).length == supportedCiphers[lowerCipher]!['size']; + } + + static String generateKey(String cipher) { + final size = supportedCiphers[cipher.toLowerCase()]?['size'] ?? 32; + final random = Random.secure(); + final bytes = List.generate(size, (_) => random.nextInt(256)); + return base64.encode(bytes); + } + + @override + String encrypt(dynamic value, [bool serialize = true]) { + if (value == null) { + throw EncryptException(); + } + + try { + final iv = encryption.IV.fromSecureRandom(16); + final encrypter = _createEncrypter(_key); + + final serializedValue = serialize ? jsonEncode(value) : value.toString(); + final encrypted = encrypter.encrypt(serializedValue, iv: iv); + + final payload = { + 'iv': base64.encode(iv.bytes), + 'value': encrypted.base64, + 'mac': _createMac(iv.bytes, encrypted.bytes, _key), + 'tag': '', // For AEAD ciphers, we'd include the tag here + }; + + return base64.encode(utf8.encode(jsonEncode(payload))); + } catch (e) { + throw EncryptException(); + } + } + + @override + String encryptString(String value) { + return encrypt(value, false); + } + + @override + dynamic decrypt(String payload, [bool unserialize = true]) { + try { + final decodedPayload = _getJsonPayload(payload); + final iv = encryption.IV(base64.decode(decodedPayload['iv'])); + + for (final key in [_key, ..._previousKeys]) { + if (_validMac(decodedPayload, key)) { + final encrypter = _createEncrypter(key); + final decrypted = + encrypter.decrypt64(decodedPayload['value'], iv: iv); + return unserialize ? jsonDecode(decrypted) : decrypted; + } + } + + throw DecryptException(); + } catch (e) { + throw DecryptException(); + } + } + + @override + String decryptString(String payload) { + return decrypt(payload, false) as String; + } + + @override + String getKey() => _key; + + @override + List getAllKeys() => [_key, ..._previousKeys]; + + @override + List getPreviousKeys() => _previousKeys; + + encryption.Encrypter _createEncrypter(String key) { + final keyBytes = base64.decode(key); + final encryptionKey = encryption.Key(keyBytes); + return encryption.Encrypter( + encryption.AES(encryptionKey, mode: encryption.AESMode.cbc)); + } + + String _createMac(List iv, List value, String key) { + final hmac = Hmac(sha256, base64.decode(key)); + final digest = hmac.convert(iv + value); + return base64.encode(digest.bytes); + } + + Map _getJsonPayload(String payload) { + try { + final decoded = jsonDecode(utf8.decode(base64.decode(payload))); + if (!_validPayload(decoded)) { + throw FormatException('The payload is invalid.'); + } + return decoded; + } catch (e) { + throw DecryptException(); + } + } + + bool _validPayload(dynamic payload) { + if (payload is! Map) return false; + for (final item in ['iv', 'value', 'mac']) { + if (!payload.containsKey(item) || payload[item] is! String) { + return false; + } + } + return true; + } + + bool _validMac(Map payload, String key) { + return _createMac( + base64.decode(payload['iv']), + base64.decode(payload['value']), + key, + ) == + payload['mac']; + } +} diff --git a/packages/encryption/lib/src/exceptions/missing_app_key_exception.dart b/packages/encryption/lib/src/exceptions/missing_app_key_exception.dart new file mode 100644 index 0000000..2c11427 --- /dev/null +++ b/packages/encryption/lib/src/exceptions/missing_app_key_exception.dart @@ -0,0 +1,14 @@ +/// Exception thrown when the application encryption key is missing. +class MissingAppKeyException implements Exception { + /// The error message. + final String message; + + /// Creates a new [MissingAppKeyException] instance. + /// + /// If no message is provided, a default message is used. + MissingAppKeyException( + [this.message = 'No application encryption key has been specified.']); + + @override + String toString() => 'MissingAppKeyException: $message'; +} diff --git a/packages/encryption/pubspec.yaml b/packages/encryption/pubspec.yaml new file mode 100644 index 0000000..9184ea5 --- /dev/null +++ b/packages/encryption/pubspec.yaml @@ -0,0 +1,18 @@ +name: platform_encryption +description: The Encryption Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platformo + +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + encrypt: ^5.0.3 + platform_contracts: ^8.0.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/encryption/test/encrypter_test.dart b/packages/encryption/test/encrypter_test.dart new file mode 100644 index 0000000..21172f5 --- /dev/null +++ b/packages/encryption/test/encrypter_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; +import 'package:test/test.dart'; +import 'package:platform_encryption/platform_encryption.dart'; +import 'package:platform_contracts/contracts.dart'; + +void main() { + late Encrypter encrypter; + late String testKey; + + setUp(() { + // Generate a 256-bit (32-byte) key for AES-256-CBC + final keyBytes = List.generate(32, (i) => i); + testKey = base64.encode(keyBytes); + encrypter = Encrypter(testKey, cipher: 'aes-256-cbc'); + }); + + test('Encrypter implements EncrypterContract and StringEncrypter', () { + expect(encrypter, isA()); + expect(encrypter, isA()); + }); + + test('Encrypt and decrypt a string', () { + const originalString = 'Hello, World!'; + final encrypted = encrypter.encrypt(originalString); + final decrypted = encrypter.decrypt(encrypted); + expect(decrypted, equals(originalString)); + }); + + test('Encrypt and decrypt an object', () { + final originalObject = {'key': 'value', 'number': 42}; + final encrypted = encrypter.encrypt(originalObject); + final decrypted = encrypter.decrypt(encrypted); + expect(decrypted, equals(originalObject)); + }); + + test('EncryptString and decryptString', () { + const originalString = 'Hello, World!'; + final encrypted = encrypter.encryptString(originalString); + final decrypted = encrypter.decryptString(encrypted); + expect(decrypted, equals(originalString)); + }); + + test('GetKey returns the correct key', () { + expect(encrypter.getKey(), equals(testKey)); + }); + + test('GetAllKeys returns all keys', () { + final previousKeyBytes = List.generate(32, (i) => i + 100); + final previousKey = base64.encode(previousKeyBytes); + final encrypterWithPreviousKey = + Encrypter(testKey, cipher: 'aes-256-cbc', previousKeys: [previousKey]); + expect( + encrypterWithPreviousKey.getAllKeys(), equals([testKey, previousKey])); + }); + + test('GetPreviousKeys returns previous keys', () { + final previousKeyBytes = List.generate(32, (i) => i + 100); + final previousKey = base64.encode(previousKeyBytes); + final encrypterWithPreviousKey = + Encrypter(testKey, cipher: 'aes-256-cbc', previousKeys: [previousKey]); + expect(encrypterWithPreviousKey.getPreviousKeys(), equals([previousKey])); + }); + + test('Throws EncryptException on encryption failure', () { + expect(() => encrypter.encrypt(null), throwsA(isA())); + }); + + test('Throws DecryptException on decryption failure', () { + expect(() => encrypter.decrypt('invalid_payload'), + throwsA(isA())); + }); +}