diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..330a91d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +# The root package has no cross-repo deps — only pointycastle (hosted). +# example/ depends on flutter_secure_dotenv_generator ^2.0.0 which is +# not yet on pub.dev, so its resolution warns during dart pub get. +# We use "|| true" because the root package always resolves; only the +# example causes exit 1. Once the generator is published, remove "|| true". + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get || true + - run: dart format --set-exit-if-changed . + - run: dart analyze --fatal-infos lib/ test/ + + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + sdk: [stable, "3.8.0"] + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + - run: dart pub get || true + - run: dart test + + dry-run: + name: Publish Dry Run + runs-on: ubuntu-latest + needs: [analyze, test] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get || true + - run: dart pub publish --dry-run + + pana: + name: Package Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub global activate pana + - run: dart pub get || true + - run: dart pub global run pana --no-warning . diff --git a/.gitignore b/.gitignore index 501d03e..d982d71 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ pubspec.lock .vscode -.env* \ No newline at end of file +.env* + +# Encryption key files generated by build_runner — never commit these. +encryption_key.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0c714..d9fb4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,23 @@ -## 1.0.0 +## 2.0.0 -- Initial version. -- Update dependencies and refactor from discontinued secure_dotenv. +- **BREAKING**: Updated `pointycastle` dependency from `^3.9.1` to `^4.0.0`. +- **BREAKING**: Minimum Dart SDK bumped from `^3.6.0` to `^3.8.0`. +- **Security**: Removed insecure `String.fromEnvironment()` / `--dart-define` pattern from examples (addresses [#2](https://github.com/mfazrinizar/flutter_secure_dotenv/issues/2)). +- Added `SECURITY.md` with detailed encryption key management guidance. +- Updated README with security warnings and recommended key provisioning approaches. +- Updated `lints` to `^6.1.0`, `test` to `^1.29.0`. +- Enhanced test coverage from 8 to 43 tests (padding, random byte generation, edge cases). +- Added fully working Flutter example app with hardcoded key + gitignore approach. +- Added 100% `public_member_api_docs` coverage. +- Made `AESCBCEncrypter` non-instantiable (static-only utility class). +- Added library-level dartdoc comments. +- Added `CONTRIBUTING.md`. ## 1.0.1 -- Refactor README and example. \ No newline at end of file +- Refactor README and example. + +## 1.0.0 + +- Initial version. +- Update dependencies and refactor from discontinued secure_dotenv. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ef5de8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to flutter_secure_dotenv + +Thank you for your interest in contributing! This guide will help you get started. + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com//flutter_secure_dotenv.git + ``` +3. Install dependencies: + ```bash + dart pub get + ``` + +## Development Workflow + +### Branching + +- `main` — stable releases published to pub.dev +- `dev` — active development; PRs should target this branch + +Create a feature branch from `dev`: + +```bash +git checkout -b feature/my-feature dev +``` + +### Code Quality + +Before submitting a PR, make sure all checks pass: + +```bash +dart format --set-exit-if-changed . +dart analyze --fatal-infos +dart test +``` + +CI runs these automatically on every push and pull request. + +### Tests + +All new features and bug fixes **must** include tests. Run the test suite with: + +```bash +dart test +``` + +## Pull Requests + +1. Keep PRs focused — one feature or fix per PR. +2. Write clear commit messages. +3. Update `CHANGELOG.md` under an `## Unreleased` section. +4. Ensure CI passes before requesting review. + +## Reporting Issues + +- Use [GitHub Issues](https://github.com/mfazrinizar/flutter_secure_dotenv/issues). +- Include Dart SDK version, package version, and a minimal reproduction. + +## Security + +If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure instructions. + +## Code of Conduct + +Be respectful and constructive in all interactions. We follow the [Dart community guidelines](https://dart.dev/community). diff --git a/README.md b/README.md index 237bea8..e083326 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Upgrade to `flutter_secure_dotenv` today and bid farewell to insecure and flawed | Android | iOS | MacOS | Web | Linux | Windows | | :-----: | :-: | :---: | :-: | :---: | :-----: | -| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ## Installing @@ -20,11 +20,11 @@ To use the `flutter_secure_dotenv` package, you need to add it as a dependency i ```yaml dependencies: - flutter_secure_dotenv: ^1.0.1 + flutter_secure_dotenv: ^2.0.0 dev_dependencies: build_runner: ^2.4.14 - flutter_secure_dotenv_generator: ^1.0.3 + flutter_secure_dotenv_generator: ^2.0.0 ``` Then, run the following command to fetch the packages: @@ -33,83 +33,150 @@ Then, run the following command to fetch the packages: $ dart pub get ``` -## Usage +## ⚠️ Security Notice: Encryption Key Management -To generate Dart classes from a `.env` file using the `flutter_secure_dotenv` package, follow the steps below: +> **NEVER** ship `encryption_key.json` or any key file inside your app bundle. A JSON file in the APK/IPA is plaintext — extractable with a simple `unzip`, no decompilation needed. The JSON file from `OUTPUT_FILE` is a **temporary transfer mechanism**: copy the key into your gitignored `env.dart`, then delete the JSON immediately, or GITIGNORE it. -1. Create a Dart file in your project and import the necessary dependencies: +**Recommended approach — hardcode in gitignored `env.dart`:** ```dart -import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; -import 'enum.dart' as e; +// ✅ env.dart (GITIGNORED — never committed to source control) +// Copy values from the temporary encryption_key.json, then delete it. +static const _encryptionKey = 'base64-key-from-encryption_key.json'; +static const _iv = 'base64-iv-from-encryption_key.json'; + +static Env create() => Env(_encryptionKey, _iv); +``` + +> **The honest trade-off**: The key IS in the compiled binary. `--obfuscate` makes it significantly harder to find but not impossible. Without a server, this is a fundamental limitation of all client-side secret management — not specific to this package. For maximum protection, fetch the key from a server at runtime. + +**Most secure approach — server-fetched key + `flutter_secure_storage`:** + +```dart +// ✅ Key never exists in the binary — fetched from server on first launch +static Future create() async { + const storage = FlutterSecureStorage(); + var key = await storage.read(key: 'env_encryption_key'); + var iv = await storage.read(key: 'env_iv'); + + if (key == null || iv == null) { + final keys = await fetchKeysFromServer(); // your secure HTTPS call + key = keys['ENCRYPTION_KEY']!; + iv = keys['IV']!; + await storage.write(key: 'env_encryption_key', value: key); + await storage.write(key: 'env_iv', value: iv); + } + + return Env(key, iv); +} +``` + +For a complete security analysis, see [SECURITY.md](SECURITY.md). + +## Usage + +To generate Dart classes from a `.env` file using the `flutter_secure_dotenv` package, follow the steps below: + +1. Create `env.example.dart` (committed to git as a template) and `.gitignore` entries: -part 'example.g.dart'; +```gitignore +# .gitignore +lib/env.dart +lib/env.g.dart +encryption_key.json +.env* ``` -2. Define the environment class and annotate it with `@DotEnvGen`: +2. Define your environment class in `env.example.dart`: ```dart +import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; + +part 'env.g.dart'; + @DotEnvGen( filename: '.env', fieldRename: FieldRename.screamingSnake, ) abstract class Env { - const factory Env(String encryptionKey) = _$Env; + // Replace with real values from encryption_key.json, then delete the JSON. + static const _encryptionKey = 'PASTE_BASE64_ENCRYPTION_KEY_HERE'; + static const _iv = 'PASTE_BASE64_IV_HERE'; + + static Env create() => Env(_encryptionKey, _iv); + + const factory Env(String encryptionKey, String iv) = _$Env; const Env._(); // Declare your environment variables as abstract getters - String get name; + String get apiKey; @FieldKey(defaultValue: 1) int get version; - e.Test? get test; + @FieldKey(name: 'DEBUG_MODE', defaultValue: 'false') + String get debugMode; +} +``` - @FieldKey(name: 'TEST_2', defaultValue: e.Test.b) - e.Test get test2; +3. Copy the template to create your real `env.dart`: - String get blah => '2'; -} +```shell +$ cp lib/env.example.dart lib/env.dart ``` -3. Generate the Dart classes by running the following command in your project's root directory: +4. Generate the encrypted env and a temporary key file: -NOTE: Encryption keys must be 128, 192, or 256 bits long. If you want to encrypt sensitive values, you can run the following command: +NOTE: Encryption keys must be 128, 192, or 256 bits long. ```shell -$ dart run build_runner build --define flutter_secure_dotenv_generator:flutter_secure_dotenv=ENCRYPTION_KEY=Your_Encryption_Key --define flutter_secure_dotenv_generator:flutter_secure_dotenv=IV=Your_IV_Key --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json +# Auto-generate random key/IV and output to a temporary file +$ dart run build_runner build \ + --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json ``` -where `encryption_key` is the encryption key you want to use to encrypt sensitive values and `your_iv` is the initialization vector. - -You can also ask flutter_secure_dotenv to generate these automatically and output them into a file: +Or provide your own key and IV: ```shell -$ dart run build_runner build --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json +$ dart run build_runner build \ + --define flutter_secure_dotenv_generator:flutter_secure_dotenv=ENCRYPTION_KEY=Your_Base64_Key \ + --define flutter_secure_dotenv_generator:flutter_secure_dotenv=IV=Your_Base64_IV \ + --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json ``` -If you don't want to encrypt sensitive values, you can run the following command instead: +If you don't need encryption at all: ```shell $ dart run build_runner build ``` -This command will generate the required Dart classes based on the `.env` file and the annotations in your code. +5. Copy the key values from `encryption_key.json` into your `env.dart`, then **delete the JSON file**: -4. Use the generated class in your code: +```shell +# Open encryption_key.json, copy ENCRYPTION_KEY and IV into env.dart, then: +$ rm encryption_key.json +``` + +> The JSON file is a **temporary transfer mechanism** only. It should never be shipped in your app or committed to git. + +6. Use the generated class in your code: ```dart void main() { - final env = Env('encryption_key'); // Provide the encryption key - print(env.name); // Access environment variables + final env = Env.create(); + print(env.apiKey); print(env.version); - print(env.test); - print(env.test2); - print(env.blah); + print(env.debugMode); } ``` +7. Build release with obfuscation: + +```shell +$ flutter build apk --obfuscate --split-debug-info=build/debug-info +``` + ## Annotations ### DotEnvGen @@ -152,9 +219,10 @@ part of 'example.dart'; // ************************************************************************** class _$Env extends Env { - const _$Env(this._encryptionKey) : super._(); + const _$Env(this._encryptionKey, this._iv) : super._(); final String _encryptionKey; + final String _iv; static final Uint8List _encryptedValues = Uint8List.fromList([81, 83,...]); @override String get name => _get('name'); @@ -215,7 +283,7 @@ This setup will ensure that the encrypted value is correctly decrypted and conve ## Limitations - The `flutter_secure_dotenv` package relies on the `build_runner` tool to generate the required code. Therefore, you need to run `dart run build_runner build` whenever changes are made to the environment class or the `.env` file. -- It is important to keep the encryption key secure and never commit it to version control or expose it in any way. +- It is important to keep the encryption key secure and never commit it to version control or expose it in any way. See the [Security Notice](#️-security-notice-encryption-key-management) section above and [SECURITY.md](SECURITY.md) for guidance. - The package currently supports encryption using the Advanced Encryption Standard (AES) algorithm in Cipher Block Chaining (CBC) mode. Other encryption algorithms and modes may be supported in the future. - Because we started using pointycastle now we only support CBC for now, but we will add support for other modes in the future. If you need another mode, please open an issue. @@ -223,12 +291,10 @@ This setup will ensure that the encrypted value is correctly decrypted and conve The `flutter_secure_dotenv` package simplifies the process of generating Dart classes from a `.env` file while encrypting sensitive values. By using this package, you can ensure that your environment variables are securely stored and accessed in your Dart application. -Rotate your secrets - make sure the old ones are not valid anymore. If you have any questions or feedback, please feel free to open an issue. +Rotate your secrets — make sure the old ones are not valid anymore. If you have any questions or feedback, please feel free to open an issue. ## Features and Bugs Please file feature requests and bugs at the [issue tracker][tracker]. [tracker]: https://github.com/mfazrinizar/flutter_secure_dotenv/issues - -
\ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f3996a7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,247 @@ +# Security Policy + +## Reporting Vulnerabilities + +If you discover a major security vulnerability, please report it responsibly by emailing **mfazrinizar@gmail.com** instead of creating a public GitHub issue. We will respond within 72 hours and work toward a fix. + +## Security Advisory: Encryption Key Provisioning + +### The Problem + +`flutter_secure_dotenv` encrypts your `.env` values at **build time**. At **runtime** the app needs the same key to decrypt. The critical question is: _how does the key reach the running app securely?_ + +### Security Ranking (Best → Worst) + +| # | Approach | Key in binary? | Key in source control? | Notes | +| --- | ---------------------------------------------------------- | :---------------: | :--------------------: | ----------------------------------------------------------------- | +| 1 | **Server-fetched key** + `flutter_secure_storage` | ❌ | ❌ | Only approach where the key never exists in the binary. | +| 2 | **Hardcoded key** in gitignored `env.dart` + `--obfuscate` | ⚠️ obfuscated | ❌ (gitignored) | Key is in the binary but protected from source control leaks. | +| 3 | `--dart-define` / `String.fromEnvironment` | ✅ base64 string | ⚠️ in CI scripts/git | Same binary risk as #2 but key also leaks into source control. | +| 4 | **JSON file** shipped in app bundle | ✅ plaintext file | ❌ | Extractable without decompiling — just `unzip`. **Worst option.** | + +> **Note on `flutter_secure_storage` without a server**: Writing a hardcoded key to `flutter_secure_storage` on first launch does **not** improve security. The `static const` is already compiled into the binary — an attacker extracts it from the binary _before the app ever runs_. Copying it to secure storage at runtime doesn't remove it from the compiled code. `flutter_secure_storage` is only meaningful when the key comes from an **external source** (a server) and never exists in the binary. + +### Why `--dart-define` Is Insecure + +1. **Build-time embedding** — `--dart-define` values are compiled directly into the binary (`libapp.so` on Android, Mach-O on iOS) as base64-encoded strings. +2. **Binary decompilation** — Anyone with the APK/IPA can extract them using `apktool`, `jadx`, or Hopper Disassembler. +3. **Memory exposure** — `String.fromEnvironment()` values exist as plain text in process memory. +4. **Security paradox** — An insecure channel for the key undermines the entire encryption layer. + +### Why a JSON File Is Even Worse + +Shipping `encryption_key.json` as a Flutter asset or alongside the binary means the key is **plaintext on disk** inside the APK/IPA. An attacker can extract it with a simple `unzip` — no decompilation needed. **Never bundle the key file in your release.** + +The JSON file generated by `build_runner` (`OUTPUT_FILE`) is a **temporary transfer mechanism** to move the key from the build tool to your gitignored `env.dart`. Delete it immediately after copying the values. + +--- + +## Recommended Approaches + +### Approach 1 — Hardcoded Key in Gitignored `env.dart` (No Server Required) + +The simplest working approach. The key lives in a source file that is **never committed**. Combined with `--obfuscate`, this is the best you can do without a server. + +> **NOTE**: The key IS in the compiled binary. `--obfuscate` makes it harder to find but not impossible for a determined attacker. This approach protects against **source control leaks** and raises the bar for reverse engineering, but it is not bulletproof. + +``` +project/ +├── lib/ +│ ├── env.dart ← GITIGNORED (contains real key) +│ ├── env.example.dart ← Committed (template with placeholders) +│ └── env.g.dart ← Generated by build_runner +├── .env ← Your secrets +└── .gitignore ← Must include: .env, env.dart, encryption_key.json +``` + +**env.example.dart** (committed): + +```dart +import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; + +part 'env.g.dart'; + +@DotEnvGen(filename: '.env', fieldRename: FieldRename.screamingSnake) +abstract class Env { + // Replace with real values from encryption_key.json, then delete the JSON. + static const _encryptionKey = 'PASTE_BASE64_ENCRYPTION_KEY_HERE'; + static const _iv = 'PASTE_BASE64_IV_HERE'; + + static Env create() => Env(_encryptionKey, _iv); + + const factory Env(String encryptionKey, String iv) = _$Env; + const Env._(); + + String get apiKey; +} +``` + +**Setup steps:** + +```bash +# 1. Copy the template +cp lib/env.example.dart lib/env.dart + +# 2. Generate encrypted env + random key +dart run build_runner build \ + --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json + +# 3. Copy ENCRYPTION_KEY and IV from encryption_key.json into env.dart + +# 4. Delete the JSON file — it was only a transfer mechanism +rm encryption_key.json + +# 5. Build release with obfuscation +flutter build apk --obfuscate --split-debug-info=build/debug-info +``` + +**What this protects:** + +- ✅ Key never enters source control or git history +- ✅ `.env` values are encrypted — not plaintext in the binary +- ⚠️ Key is in the binary but obfuscated — raises the difficulty bar for reverse engineering + +**What this does NOT protect:** + +- ❌ A determined attacker with decompilation skills can still find the key in the binary +- ❌ No key rotation without rebuilding and re-deploying + +--- + +### Approach 2 — Server-Fetched Key + `flutter_secure_storage` (Most Secure) + +The **only** approach where the key never exists in the app binary. It is fetched from a backend secrets manager on first launch and cached in platform secure storage (Android Keystore / iOS Keychain). + +```dart +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +static Future create() async { + const storage = FlutterSecureStorage(); + + var key = await storage.read(key: 'env_encryption_key'); + var iv = await storage.read(key: 'env_iv'); + + if (key == null || iv == null) { + // Fetch from your backend over HTTPS + final response = await http.get( + Uri.parse('https://your-api.com/env-keys'), + headers: {'Authorization': 'Bearer $token'}, + ); + final keys = json.decode(response.body) as Map; + key = keys['ENCRYPTION_KEY'] as String; + iv = keys['IV'] as String; + + await storage.write(key: 'env_encryption_key', value: key); + await storage.write(key: 'env_iv', value: iv); + } + + return Env(key, iv); +} +``` + +Use a secrets manager such as: + +- [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) +- [Google Secret Manager](https://cloud.google.com/secret-manager) +- [HashiCorp Vault](https://www.vaultproject.io/) +- [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault/) + +**What this protects:** + +- ✅ Key never in the binary — cannot be extracted by decompilation +- ✅ Key never in source control +- ✅ Supports key rotation without rebuilding the app +- ✅ After first launch, key lives in OS-encrypted hardware-backed storage + +**What this does NOT protect:** + +- ❌ Memory dump on rooted/jailbroken device (decrypted values in RAM) +- ❌ Requires a backend server (needs to be secure) and internet on first launch + +--- + +## What This Package Does and Does NOT Protect + +| Threat | Protected? | Notes | +| --------------------------------------- | :----------: | ----------------------------------------------------------------------------- | +| Source code leak of `.env` file | ✅ Yes | Values are encrypted at build time | +| Source control leak of encryption key | ✅ Yes | Key is in gitignored file, never committed | +| Reverse-engineering APK/IPA for secrets | ⚠️ Partial | Encrypted values are safe; key security depends on approach + `--obfuscate` | +| Reverse-engineering APK/IPA for the key | ⚠️ Depends | Approach 2 (server): ✅. Approach 1 (hardcoded): ⚠️ obfuscated but in binary | +| Memory dump on rooted device | ⚠️ Mitigable | See [Memory Protection](#memory-protection-on-rootedjailbroken-devices) below | +| Man-in-the-middle attacks | ❌ No | Use HTTPS + certificate pinning separately | + +### Memory Protection on Rooted/Jailbroken Devices + +Decrypted values must exist in app memory to be used — a root-level attacker who can dump memory at the right moment can read them. This is a fundamental limitation of **every** app on **every** platform, not specific to this package. + +> **Note on `flutter_secure_storage`**: The data _at rest_ in Android Keystore or iOS Keychain is hardware-encrypted and safe even on rooted devices. However, the moment you call `storage.read()`, the returned `String` is a plain Dart object in app memory — and that is dumpable. The storage protects the _persisted_ data; the vulnerability is the read-into-memory step, which is unavoidable if you need to actually use the value. + +Available mitigations (none are bulletproof, but they stack): + +| Mitigation | Effectiveness | Notes | +| ----------------------------------- | :-----------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Root/jailbreak detection** | ⚠️ | Refuse to run on compromised devices. Bypassable via Frida hooks or patched binaries. Packages: [`flutter_jailbreak_detection`](https://pub.dev/packages/flutter_jailbreak_detection). | +| **Hardware-backed crypto** | ⚠️ | Use Android Keystore TEE/StrongBox or iOS Secure Enclave to decrypt in hardware — the **key** never enters app memory. But the **decrypted values** still must. | +| **Minimize exposure window** | ⚠️ | Decrypt lazily (on-demand), don't cache, null-out references after use. Limited in Dart — strings are immutable and the GC controls when memory is freed, so you can't reliably zero it. | +| **Anti-tampering / anti-debugging** | ⚠️ | Detect Frida, debuggers, and memory inspection tools at runtime. Bypassable by skilled attackers. | +| **Short-lived tokens** | ✅ | Design your backend so secrets are short-lived tokens that expire quickly. Even if dumped, they become useless. This is an architectural solution, not a client-side one. | + +**Bottom line**: The only robust defense against memory inspection is making stolen secrets useless — short-lived tokens, server-side rate limiting, and device attestation. No amount of client-side hardening fully solves this. + +## The Hard Truth About Client-Side Key Storage + +No purely client-side approach can fully protect an encryption key from a determined attacker. If the key must be in the binary (because there's no server), it can be extracted — `--obfuscate` raises the bar but doesn't eliminate the risk. + +This is a fundamental limitation of **all** client-side secret management, not specific to this package. + +### What Should Go in `.env`? + +Your `.env` file should contain **semi-secrets** — values that need to be in the client at build time but are **already protected by server-side rules**. The encryption adds a layer that makes casual extraction and automated scraping harder, but it is not the primary security boundary. + +**Good candidates for `.env` (semi-secrets with server-side protection):** + +| Value | Why it's okay | Real protection layer | +| -------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | +| Firebase API key | Must be in client; public by design | Firestore Security Rules, App Check | +| API base URL | Not truly secret, but shouldn't be trivially scraped | Rate limiting, authentication | +| Sentry DSN | Needed at build time | Server-side rate limiting, project access controls | +| Third-party SDK keys (Maps, Analytics) | Must be in client | Usage quotas, domain/bundle restrictions | +| Feature flags / config | Non-sensitive but shouldn't be plaintext | Server-side validation | + +**Bad candidates for `.env` (truly secret — do NOT embed in client):** + +| Value | Why it's dangerous | What to do instead | +| ----------------------------- | -------------------------- | ---------------------------------- | +| Database credentials | Full data access if leaked | Server-side only — never in client | +| Admin API keys | Unrestricted access | Server-side only | +| Payment processor secret keys | Financial access | Server-side only | +| JWT signing secrets | Can forge tokens | Server-side only | + +### The Right Mental Model + +Think of `flutter_secure_dotenv` encryption as a **speed bump, not a wall**: + +1. **It prevents casual leaks** — `.env` values aren't plaintext in your repo or binary +2. **It raises the effort required** — an attacker needs decompilation skills + obfuscation bypassing, not just `unzip` or `strings` +3. **It defeats automated scanning** — secret-scanning bots and scrapers won't find encrypted values +4. **It reduces spam/abuse potential** — even semi-public keys like Firebase API keys benefit from not being trivially extractable, because it reduces the pool of people who can abuse them + +The real security comes from the **server side** — rules, quotas, authentication, rate limiting, and App Check. The encryption just makes it harder for someone to reach the point of attempting abuse. + +## Summary + +- **Never** pass encryption keys via `--dart-define`. +- **Never** ship `encryption_key.json` or any key file in your app bundle — it is plaintext. +- **Never** commit `env.dart` to source control — commit `env.example.dart` as a template. +- **Always** build release builds with `--obfuscate --split-debug-info=...`. +- **Always** add `env.dart`, `encryption_key.json`, and `.env*` to `.gitignore`. +- For maximum security, use a server to provision the key (Approach 2). +- Without a server, use the gitignored `env.dart` + `--obfuscate` pattern (Approach 1) and accept the trade-off. + +## Supported Versions + +| Version | Supported | +| ------- | ---------------------- | +| 2.x | ✅ Current | +| 1.x | ⚠️ Security fixes only | diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..4e3a927 --- /dev/null +++ b/example/.env.example @@ -0,0 +1,2 @@ +API_BASE_URL=YOUR_API_BASE_URL_HERE +API_WEB_SOCKET_URL=YOUR_API_WEB_SOCKET_URL_HERE \ No newline at end of file diff --git a/example/.gitignore b/example/.gitignore index 1e18f27..c124eec 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1 +1,26 @@ -!.env \ No newline at end of file +# Flutter / Dart +.dart_tool/ +.packages +build/ +*.iml +*.lock +.flutter-plugins +.flutter-plugins-dependencies + +# In real repository, don't commit env files with real secrets. See .env.example for an example of what to commit. +.env* +# Commit the example env file, it will be used as a template for users to create their own .env file. +!.env.example + +# The real env.dart is gitignored — only env.example.dart is committed. +# env.dart contains the hardcoded encryption key and should NEVER be +# committed to version control. +lib/env.dart +lib/env.g.dart + +# Temporary key file generated by build_runner — delete after copying +# the values into env.dart. +encryption_key.json + +.idea/ +pubspec_overrides.yaml \ No newline at end of file diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..defd946 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: android + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..dc5a61f --- /dev/null +++ b/example/README.md @@ -0,0 +1,115 @@ +# flutter_secure_dotenv Example + +A fully working Flutter app demonstrating the **hardcoded key + gitignore** +approach for managing encrypted environment variables with +[flutter_secure_dotenv](https://pub.dev/packages/flutter_secure_dotenv). + +## Quick Start + +### 1. Install dependencies + +```bash +flutter pub get +``` + +### 2. Create your `.env` file + +A sample `.env` is already included. For a real project, add your secrets: + +```dotenv +API_BASE_URL=https://api.example.com +API_WEB_SOCKET_URL=wss://api.example.com/socket +``` + +### 3. Create `lib/env.dart` from the template + +```bash +cp lib/env.example.dart lib/env.dart +``` + +### 4. Run `build_runner` + +```bash +dart run build_runner build +``` + +This generates two files: + +- `lib/env.g.dart` — your `.env` values encrypted as a byte array +- `encryption_key.json` — the key and IV used for encryption + +### 5. Copy the key into `lib/env.dart` + +Open `encryption_key.json`: + +```json +{ + "ENCRYPTION_KEY": "base64-encoded-key...", + "IV": "base64-encoded-iv..." +} +``` + +Paste `ENCRYPTION_KEY` and `IV` into the constants in `lib/env.dart`: + +```dart +static const _encryptionKey = 'base64-encoded-key...'; +static const _iv = 'base64-encoded-iv...'; +``` + +### 6. Delete `encryption_key.json` + +```bash +rm encryption_key.json +``` + +### 7. Run the app + +```bash +flutter run +``` + +The app displays the decrypted environment variables in a simple Material UI. + +## Verify with Dart CLI + +You can verify decryption without running the full Flutter app: + +```bash +dart run bin/verify.dart +``` + +## Project Structure + +``` +example/ +├── .env # Sample environment variables +├── .gitignore # Ignores lib/env.dart, lib/env.g.dart, encryption_key.json +├── build.yaml # Generator configuration (OUTPUT_FILE) +├── pubspec.yaml # Flutter app dependencies +├── bin/ +│ └── verify.dart # CLI verification script +└── lib/ + ├── main.dart # Flutter app UI + ├── env.example.dart # Committed template — copy to env.dart + ├── env.dart # Real env file with encryption key (GITIGNORED) + └── env.g.dart # Generated encrypted values (GITIGNORED) +``` + +## Important Notes + +- **`lib/env.dart` is gitignored** — it contains the encryption key and must + never be committed. Only `lib/env.example.dart` is checked in. +- **`encryption_key.json` is temporary** — delete it after copying the key + into `lib/env.dart`. +- **Don't rebuild after pasting the key** — `env.g.dart` is already encrypted + with the matching key from the same build run. Rebuilding generates a new + random key that won't match. +- For production, build with `flutter build apk --obfuscate --split-debug-info=debug-info` + to make the key harder to extract from the binary. + +## Security + +The encryption key lives in the compiled binary. `--obfuscate` makes it harder +to find but not impossible. This approach is a **speed bump against casual +leaks**, not a fully cryptographic guarantee. For truly sensitive secrets, use a +server-fetched key — see the package [SECURITY.md](../SECURITY.md). diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..5031f5d --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + # Template file — not meant to be compiled directly. + - lib/env.example.dart + +linter: + rules: + avoid_print: false diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..84a7af4 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.flutter_secure_dotenv_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.flutter_secure_dotenv_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f156b29 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/flutter_secure_dotenv_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/flutter_secure_dotenv_example/MainActivity.kt new file mode 100644 index 0000000..a1b5293 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/flutter_secure_dotenv_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.flutter_secure_dotenv_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/example/bin/verify.dart b/example/bin/verify.dart new file mode 100644 index 0000000..f596ae5 --- /dev/null +++ b/example/bin/verify.dart @@ -0,0 +1,12 @@ +// Quick verification script — run with: dart run bin/verify.dart +import 'package:flutter_secure_dotenv_example/env.dart'; + +void main() { + final env = Env.create(); + print('API_BASE_URL: ${env.apiBaseUrl}'); + print('API_WEB_SOCKET_URL: ${env.apiWebSocketUrl}'); + + assert(env.apiBaseUrl == 'https://api.example.com'); + assert(env.apiWebSocketUrl == 'wss://api.example.com/socket'); + print('\nAll values decrypted correctly!'); +} diff --git a/example/build.yaml b/example/build.yaml new file mode 100644 index 0000000..cd8e8bf --- /dev/null +++ b/example/build.yaml @@ -0,0 +1,13 @@ +targets: + $default: + builders: + flutter_secure_dotenv_generator:flutter_secure_dotenv: + # Set to false if you don't need the encryption key output file. + # When enabled, generates encryption_key.json with the key and IV + # that you must copy into your env.dart file. + enabled: true + generate_for: + exclude: + - lib/env.example.dart + options: + OUTPUT_FILE: encryption_key.json diff --git a/example/flutter_secure_dotenv_example.dart b/example/flutter_secure_dotenv_example.dart deleted file mode 100644 index 0f850aa..0000000 --- a/example/flutter_secure_dotenv_example.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; - -part 'env.g.dart'; - -@DotEnvGen( - filename: '.env', - fieldRename: FieldRename.screamingSnake, -) -abstract class Env { - static Env create() { - String encryptionKey = const String.fromEnvironment( - "APP_ENCRYPTION_KEY"); // On build, change with your generated encryption key (use dart-define for String.fromEnvironment) - String iv = const String.fromEnvironment( - "APP_IV_KEY"); // On build, change with your generated iv (use dart-define for String.fromEnvironment) - return Env(encryptionKey, iv); - } - - const factory Env(String encryptionKey, String iv) = _$Env; - - const Env._(); - - @FieldKey(defaultValue: "") - String get apiBaseUrl; - - @FieldKey(defaultValue: "") - String get apiWebSocketUrl; -} diff --git a/example/lib/env.example.dart b/example/lib/env.example.dart new file mode 100644 index 0000000..4f54ae3 --- /dev/null +++ b/example/lib/env.example.dart @@ -0,0 +1,44 @@ +// ────────────────────────────────────────────────────────────────────── +// env.example.dart — TEMPLATE (committed to git) +// +// Copy this file to env.dart (same directory) and fill in the real +// key/IV. The real env.dart is GITIGNORED — it must never be committed. +// ────────────────────────────────────────────────────────────────────── +// +// Steps: +// 1. Copy: cp lib/env.example.dart lib/env.dart +// 2. Build: dart run build_runner build +// 3. Copy key: open encryption_key.json, paste ENCRYPTION_KEY and IV +// into the constants in lib/env.dart. +// 4. Delete: rm encryption_key.json +// 5. Use it: import 'env.dart' in your app code and call Env.create(). +// +// For maximum security, use a server-fetched key at runtime. +// See the package SECURITY.md for details. +// ────────────────────────────────────────────────────────────────────── + +import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; + +part 'env.g.dart'; + +@DotEnvGen(filename: '.env', fieldRename: FieldRename.screamingSnake) +abstract class Env { + // ── Replace these placeholders with real values from encryption_key.json ── + static const _encryptionKey = 'PASTE_BASE64_ENCRYPTION_KEY_HERE'; + static const _iv = 'PASTE_BASE64_IV_HERE'; + + /// Creates an [Env] instance using the hardcoded encryption key. + static Env create() { + return Env(_encryptionKey, _iv); + } + + const factory Env(String encryptionKey, String iv) = _$Env; + + const Env._(); + + @FieldKey(defaultValue: '') + String get apiBaseUrl; + + @FieldKey(defaultValue: '') + String get apiWebSocketUrl; +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..fab1556 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'env.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'flutter_secure_dotenv Example', + theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true), + home: const EnvDemoPage(), + ); + } +} + +class EnvDemoPage extends StatefulWidget { + const EnvDemoPage({super.key}); + + @override + State createState() => _EnvDemoPageState(); +} + +class _EnvDemoPageState extends State { + late final Env _env; + String? _error; + + @override + void initState() { + super.initState(); + try { + _env = Env.create(); + } catch (e) { + _error = e.toString(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Secure Dotenv Demo')), + body: _error != null ? _buildError() : _buildEnvTable(), + ); + } + + Widget _buildError() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Failed to load env:\n$_error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _buildEnvTable() { + final entries = >[ + MapEntry('API_BASE_URL', _env.apiBaseUrl), + MapEntry('API_WEB_SOCKET_URL', _env.apiWebSocketUrl), + ]; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Decrypted Environment Variables', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'These values were encrypted at build time and decrypted at runtime ' + 'using flutter_secure_dotenv.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + ...entries.map( + (e) => Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + title: Text( + e.key, + style: const TextStyle(fontFamily: 'monospace'), + ), + subtitle: Text(e.value), + ), + ), + ), + ], + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..ff5d106 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: flutter_secure_dotenv_example +description: Example Flutter app demonstrating flutter_secure_dotenv usage. +publish_to: none +version: 1.0.0 + +environment: + sdk: ^3.8.0 + +dependencies: + flutter: + sdk: flutter + flutter_secure_dotenv: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_secure_dotenv_generator: ^2.0.0 + build_runner: ^2.11.1 + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/example/pubspec_overrides.yaml.example b/example/pubspec_overrides.yaml.example new file mode 100644 index 0000000..a425208 --- /dev/null +++ b/example/pubspec_overrides.yaml.example @@ -0,0 +1,8 @@ +# Local development overrides. Rename to pubspec_overrides.yaml for local testing. +# Forces both packages to resolve to local paths, avoiding +# hosted vs path conflicts during development. +dependency_overrides: + flutter_secure_dotenv: + path: ../ + flutter_secure_dotenv_generator: + path: ../../flutter_secure_dotenv_generator diff --git a/lib/flutter_secure_dotenv.dart b/lib/flutter_secure_dotenv.dart index b455c6e..dc1f69e 100644 --- a/lib/flutter_secure_dotenv.dart +++ b/lib/flutter_secure_dotenv.dart @@ -1,3 +1,7 @@ +/// Securely manage environment variables with AES-CBC encryption. +/// +/// Provides [DotEnvGen] for annotating classes, [AESCBCEncrypter] for +/// encryption/decryption, and [FieldKey]/[FieldRename] for field configuration. library; export 'dart:convert' show json, base64; diff --git a/lib/src/encrypter.dart b/lib/src/encrypter.dart index e565a66..137af8c 100644 --- a/lib/src/encrypter.dart +++ b/lib/src/encrypter.dart @@ -6,6 +6,9 @@ import "package:pointycastle/export.dart"; /// A class that provides AES encryption and decryption using CBC mode. class AESCBCEncrypter { + /// This class is not meant to be instantiated. Use static methods instead. + AESCBCEncrypter._(); + /// Encrypts the given [text] using AES in CBC mode with the provided [key] and [iv]. /// /// The [key] must be 128, 192, or 256 bits long. @@ -13,11 +16,7 @@ class AESCBCEncrypter { /// The [text] is padded using PKCS7 padding. /// /// Returns the encrypted text as a [Uint8List]. - static Uint8List aesCbcEncrypt( - Uint8List key, - Uint8List iv, - String text, - ) { + static Uint8List aesCbcEncrypt(Uint8List key, Uint8List iv, String text) { final paddedPlaintext = pad(utf8.encode(text), 16); if (![128, 192, 256].contains(key.length * 8)) { throw ArgumentError.value(key, 'key', 'invalid key length for AES'); @@ -27,7 +26,10 @@ class AESCBCEncrypter { } if (paddedPlaintext.length * 8 % 128 != 0) { throw ArgumentError.value( - paddedPlaintext, 'paddedPlaintext', 'invalid length for AES'); + paddedPlaintext, + 'paddedPlaintext', + 'invalid length for AES', + ); } // Create a CBC block cipher with AES, and initialize with key and IV @@ -82,7 +84,10 @@ class AESCBCEncrypter { } if (cipherText.length * 8 % 128 != 0) { throw ArgumentError.value( - cipherText, 'cipherText', 'invalid length for AES'); + cipherText, + 'cipherText', + 'invalid length for AES', + ); } // Create a CBC block cipher with AES, and initialize with key and IV diff --git a/lib/src/field_key.dart b/lib/src/field_key.dart index f85e41b..063d66b 100644 --- a/lib/src/field_key.dart +++ b/lib/src/field_key.dart @@ -3,10 +3,7 @@ part of '../flutter_secure_dotenv.dart'; /// Represents a key in the environment variables. class FieldKey { /// Creates a [FieldKey] with the given [name] and [defaultValue]. - const FieldKey({ - this.name, - this.defaultValue, - }); + const FieldKey({this.name, this.defaultValue}); /// The name of the field key. final String? name; diff --git a/pubspec.yaml b/pubspec.yaml index 64b32ca..36f5485 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,14 +1,14 @@ repository: https://github.com/mfazrinizar/flutter_secure_dotenv name: flutter_secure_dotenv description: Package to securely manage environment variables and perform AES encryption/decryption in Flutter and Dart. -version: 1.0.1 +version: 2.0.0 environment: - sdk: ^3.6.0 + sdk: ^3.8.0 dependencies: - pointycastle: ^3.9.1 + pointycastle: ^4.0.0 dev_dependencies: - lints: ^5.0.0 - test: ^1.24.0 + lints: ^6.1.0 + test: ^1.29.0 diff --git a/test/flutter_secure_dotenv_test.dart b/test/flutter_secure_dotenv_test.dart index dbb311c..9e90090 100644 --- a/test/flutter_secure_dotenv_test.dart +++ b/test/flutter_secure_dotenv_test.dart @@ -1,74 +1,397 @@ +import 'dart:convert'; + import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart'; import 'package:test/test.dart'; void main() { - group('DotEnvGen Tests', () { - test('Default values', () { + // ── DotEnvGen Tests ────────────────────────────────────────────────────── + + group('DotEnvGen', () { + test('default values', () { final dotenv = DotEnvGen(); expect(dotenv.filename, '.env'); expect(dotenv.fieldRename, FieldRename.screamingSnake); }); - test('Custom values', () { - final dotenv = - DotEnvGen(filename: 'custom.env', fieldRename: FieldRename.snake); + test('custom values', () { + final dotenv = DotEnvGen( + filename: 'custom.env', + fieldRename: FieldRename.snake, + ); expect(dotenv.filename, 'custom.env'); expect(dotenv.fieldRename, FieldRename.snake); }); + + test('all FieldRename values are accessible', () { + for (final rename in FieldRename.values) { + final dotenv = DotEnvGen(fieldRename: rename); + expect(dotenv.fieldRename, rename); + } + }); + }); + + // ── FieldKey Tests ─────────────────────────────────────────────────────── + + group('FieldKey', () { + test('default construction', () { + const key = FieldKey(); + expect(key.name, isNull); + expect(key.defaultValue, isNull); + }); + + test('with name', () { + const key = FieldKey(name: 'MY_KEY'); + expect(key.name, 'MY_KEY'); + expect(key.defaultValue, isNull); + }); + + test('with default value', () { + const key = FieldKey(defaultValue: 42); + expect(key.name, isNull); + expect(key.defaultValue, 42); + }); + + test('with both name and default value', () { + const key = FieldKey(name: 'PORT', defaultValue: 8080); + expect(key.name, 'PORT'); + expect(key.defaultValue, 8080); + }); + + test('default value can be non-int types', () { + const stringKey = FieldKey(defaultValue: 'hello'); + expect(stringKey.defaultValue, 'hello'); + + const boolKey = FieldKey(defaultValue: true); + expect(boolKey.defaultValue, true); + + const doubleKey = FieldKey(defaultValue: 3.14); + expect(doubleKey.defaultValue, 3.14); + }); }); - group('AESCBCEncrypter Tests', () { - final key = Uint8List.fromList(List.generate(32, (i) => i)); // 256-bit key + // ── AESCBCEncrypter Tests ──────────────────────────────────────────────── + + group('AESCBCEncrypter', () { + // Standard test vectors + final key128 = Uint8List.fromList( + List.generate(16, (i) => i), + ); // 128-bit key + final key192 = Uint8List.fromList( + List.generate(24, (i) => i), + ); // 192-bit key + final key256 = Uint8List.fromList( + List.generate(32, (i) => i), + ); // 256-bit key final iv = Uint8List.fromList(List.generate(16, (i) => i)); // 128-bit IV - final text = 'Hello, World!'; - test('AES CBC Encrypt and Decrypt', () { - final encrypted = AESCBCEncrypter.aesCbcEncrypt(key, iv, text); - final decrypted = AESCBCEncrypter.aesCbcDecrypt(key, iv, encrypted); - expect(decrypted, text); + group('encrypt and decrypt round-trip', () { + test('with 128-bit key', () { + const text = 'Hello, World!'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key128, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key128, iv, encrypted); + expect(decrypted, text); + }); + + test('with 192-bit key', () { + const text = 'Hello, World!'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key192, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key192, iv, encrypted); + expect(decrypted, text); + }); + + test('with 256-bit key', () { + const text = 'Hello, World!'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with empty string', () { + const text = ''; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with long text', () { + final text = 'A' * 10000; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with special characters', () { + const text = '!@#\$%^&*()_+-=[]{}|;:\'",.<>?/~`\n\t\r'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with unicode characters', () { + const text = '你好世界 🌍 مرحبا العالم こんにちは世界'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with JSON content', () { + final jsonContent = jsonEncode({ + 'API_KEY': 'sk-1234567890', + 'SECRET': 'my-super-secret', + 'DEBUG': 'true', + 'PORT': '3000', + }); + final encrypted = AESCBCEncrypter.aesCbcEncrypt( + key256, + iv, + jsonContent, + ); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, jsonContent); + expect(jsonDecode(decrypted), isA()); + }); + + test('with text exactly one block long (16 bytes)', () { + const text = '0123456789ABCDEF'; // exactly 16 bytes ASCII + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); + + test('with text exactly two blocks long (32 bytes)', () { + const text = '0123456789ABCDEF0123456789ABCDEF'; // 32 bytes + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final decrypted = AESCBCEncrypter.aesCbcDecrypt(key256, iv, encrypted); + expect(decrypted, text); + }); }); - test('AES CBC Encrypt with invalid key length', () { - final invalidKey = - Uint8List.fromList(List.generate(10, (i) => i)); // Invalid key length - expect( - () => AESCBCEncrypter.aesCbcEncrypt(invalidKey, iv, text), - throwsArgumentError, - ); + group('encryption produces ciphertext', () { + test('ciphertext differs from plaintext', () { + const text = 'Hello, World!'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final plainBytes = Uint8List.fromList(utf8.encode(text)); + expect(encrypted, isNot(equals(plainBytes))); + }); + + test('ciphertext length is a multiple of block size', () { + const text = 'Hello'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + expect(encrypted.length % 16, 0); + }); + + test('different keys produce different ciphertext', () { + const text = 'Hello, World!'; + final encrypted1 = AESCBCEncrypter.aesCbcEncrypt(key128, iv, text); + final encrypted2 = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + expect(encrypted1, isNot(equals(encrypted2))); + }); + + test('different IVs produce different ciphertext', () { + const text = 'Hello, World!'; + final iv2 = Uint8List.fromList(List.generate(16, (i) => i + 100)); + final encrypted1 = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final encrypted2 = AESCBCEncrypter.aesCbcEncrypt(key256, iv2, text); + expect(encrypted1, isNot(equals(encrypted2))); + }); + + test('same inputs produce same ciphertext (deterministic)', () { + const text = 'Hello, World!'; + final encrypted1 = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + final encrypted2 = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + expect(encrypted1, equals(encrypted2)); + }); }); - test('AES CBC Encrypt with invalid IV length', () { - final invalidIv = - Uint8List.fromList(List.generate(10, (i) => i)); // Invalid IV length - expect( - () => AESCBCEncrypter.aesCbcEncrypt(key, invalidIv, text), - throwsArgumentError, - ); + group('invalid key lengths', () { + test('encrypt rejects 10-byte key', () { + final invalidKey = Uint8List.fromList(List.generate(10, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(invalidKey, iv, 'test'), + throwsArgumentError, + ); + }); + + test('encrypt rejects 0-byte key', () { + final emptyKey = Uint8List(0); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(emptyKey, iv, 'test'), + throwsArgumentError, + ); + }); + + test('encrypt rejects 15-byte key', () { + final invalidKey = Uint8List.fromList(List.generate(15, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(invalidKey, iv, 'test'), + throwsArgumentError, + ); + }); + + test('encrypt rejects 33-byte key', () { + final invalidKey = Uint8List.fromList(List.generate(33, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(invalidKey, iv, 'test'), + throwsArgumentError, + ); + }); + + test('decrypt rejects invalid key length', () { + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, 'test'); + final invalidKey = Uint8List.fromList(List.generate(10, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcDecrypt(invalidKey, iv, encrypted), + throwsArgumentError, + ); + }); }); - test('AES CBC Decrypt with invalid key length', () { - final encrypted = AESCBCEncrypter.aesCbcEncrypt(key, iv, text); - final invalidKey = - Uint8List.fromList(List.generate(10, (i) => i)); // Invalid key length - expect( - () => AESCBCEncrypter.aesCbcDecrypt(invalidKey, iv, encrypted), - throwsArgumentError, - ); + group('invalid IV lengths', () { + test('encrypt rejects 10-byte IV', () { + final invalidIv = Uint8List.fromList(List.generate(10, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(key256, invalidIv, 'test'), + throwsArgumentError, + ); + }); + + test('encrypt rejects 0-byte IV', () { + final emptyIv = Uint8List(0); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(key256, emptyIv, 'test'), + throwsArgumentError, + ); + }); + + test('encrypt rejects 32-byte IV', () { + final longIv = Uint8List.fromList(List.generate(32, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcEncrypt(key256, longIv, 'test'), + throwsArgumentError, + ); + }); + + test('decrypt rejects invalid IV length', () { + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, 'test'); + final invalidIv = Uint8List.fromList(List.generate(10, (i) => i)); + expect( + () => AESCBCEncrypter.aesCbcDecrypt(key256, invalidIv, encrypted), + throwsArgumentError, + ); + }); }); - test('AES CBC Decrypt with invalid IV length', () { - final encrypted = AESCBCEncrypter.aesCbcEncrypt(key, iv, text); - final invalidIv = - Uint8List.fromList(List.generate(10, (i) => i)); // Invalid IV length - expect( - () => AESCBCEncrypter.aesCbcDecrypt(key, invalidIv, encrypted), - throwsArgumentError, - ); + group('invalid ciphertext', () { + test('decrypt rejects non-block-aligned ciphertext', () { + final invalidCiphertext = Uint8List.fromList([1, 2, 3, 4, 5]); + expect( + () => AESCBCEncrypter.aesCbcDecrypt(key256, iv, invalidCiphertext), + throwsArgumentError, + ); + }); + }); + + group('padding', () { + test('pad produces correct length', () { + final input = Uint8List.fromList([1, 2, 3, 4, 5]); + final padded = AESCBCEncrypter.pad(input, 16); + expect(padded.length % 16, 0); + expect(padded.length, 16); + }); + + test('pad and unpad round-trip', () { + final input = Uint8List.fromList([1, 2, 3, 4, 5]); + final padded = AESCBCEncrypter.pad(input, 16); + final unpadded = AESCBCEncrypter.unpad(padded); + expect(unpadded, equals(input)); + }); + + test('pad full block adds extra block', () { + final input = Uint8List.fromList(List.generate(16, (i) => i)); + final padded = AESCBCEncrypter.pad(input, 16); + expect(padded.length, 32); // PKCS7 adds a full padding block + }); + + test('pad and unpad with different block-boundary sizes', () { + for (var size = 0; size <= 32; size++) { + final input = Uint8List.fromList(List.generate(size, (i) => i % 256)); + final padded = AESCBCEncrypter.pad(input, 16); + expect(padded.length % 16, 0); + final unpadded = AESCBCEncrypter.unpad(padded); + expect(unpadded, equals(input)); + } + }); + }); + + group('generateRandomBytes', () { + test('generates correct number of bytes', () { + for (final n in [1, 16, 32, 64, 128, 256]) { + final bytes = AESCBCEncrypter.generateRandomBytes(n); + expect(bytes.length, n); + } + }); + + test('generates different output on subsequent calls', () { + final bytes1 = AESCBCEncrypter.generateRandomBytes(32); + final bytes2 = AESCBCEncrypter.generateRandomBytes(32); + // Statistically near-impossible for two random 32-byte values to match + expect(bytes1, isNot(equals(bytes2))); + }); + + test('generated key works for encryption', () { + final randomKey = AESCBCEncrypter.generateRandomBytes(32); + final randomIv = AESCBCEncrypter.generateRandomBytes(16); + const text = 'Test with random key'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt( + randomKey, + randomIv, + text, + ); + final decrypted = AESCBCEncrypter.aesCbcDecrypt( + randomKey, + randomIv, + encrypted, + ); + expect(decrypted, text); + }); + }); + + group('cross-key decryption failure', () { + test('decrypting with wrong key does not produce original text', () { + const text = 'Secret data'; + final encrypted = AESCBCEncrypter.aesCbcEncrypt(key256, iv, text); + // Decryption with wrong key should either throw or produce garbage + try { + final wrongKey = Uint8List.fromList( + List.generate(32, (i) => 255 - i), + ); + final result = AESCBCEncrypter.aesCbcDecrypt(wrongKey, iv, encrypted); + expect(result, isNot(equals(text))); + } catch (_) { + // Expected — wrong key may cause a padding error + } + }); + }); + }); + + // ── FieldRename Enum Tests ─────────────────────────────────────────────── + + group('FieldRename', () { + test('has all expected values', () { + expect(FieldRename.values, hasLength(5)); + expect(FieldRename.values, contains(FieldRename.none)); + expect(FieldRename.values, contains(FieldRename.kebab)); + expect(FieldRename.values, contains(FieldRename.snake)); + expect(FieldRename.values, contains(FieldRename.pascal)); + expect(FieldRename.values, contains(FieldRename.screamingSnake)); }); - test('Generate random bytes', () { - final randomBytes = AESCBCEncrypter.generateRandomBytes(16); - expect(randomBytes.length, 16); + test('enum names match', () { + expect(FieldRename.none.name, 'none'); + expect(FieldRename.kebab.name, 'kebab'); + expect(FieldRename.snake.name, 'snake'); + expect(FieldRename.pascal.name, 'pascal'); + expect(FieldRename.screamingSnake.name, 'screamingSnake'); }); }); }