diff --git a/.gitignore b/.gitignore index 9c22a5a..d39a77d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ # ZeroEnv master key .secrets.key + +# Python build artifacts +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/src/zeroenv.egg-info/PKG-INFO b/src/zeroenv.egg-info/PKG-INFO new file mode 100644 index 0000000..40b1ab9 --- /dev/null +++ b/src/zeroenv.egg-info/PKG-INFO @@ -0,0 +1,534 @@ +Metadata-Version: 2.4 +Name: zeroenv +Version: 0.1.0 +Summary: Simple, secure, and git-safe secrets management.. +Author-email: "ropeadope62 (Dave C.)" +Project-URL: Homepage, https://github.com/ropeadope62/zeroenv +Project-URL: Bug Tracker, https://github.com/ropeadope62/zeroenv/issues +Classifier: Development Status :: 2 - Pre-Alpha +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: System Administrators +Classifier: Topic :: Security :: Cryptography +Classifier: Topic :: Software Development :: Libraries +Classifier: Topic :: Utilities +Classifier: Environment :: Console +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: click +Requires-Dist: cryptography +Requires-Dist: rich +Dynamic: license-file + +

+
+ ZeroEnv +

+ +

Simple, secure, and git-safe secrets management.

+ +

+ + PyPI version + + + + + + + + + Tests + +

+ +

+ Features • + Installation • + Usage • + How It Works • + Team Workflow • + Docker • + Testing • + Security • + Command Reference +

+ +![demo](./assets/zeroenv_example.gif) + +## Features + +* AES-256-GCM encryption with unique nonces +* Git-safe encrypted storage (commit `.secrets`, not keys) +* Zero-config initialization +* No code changes required (injects env vars) +* No infrastructure dependencies +* Cross-platform (Windows, macOS, Linux) +* Export to `.env` format + +## Installation + +```bash +pip install zeroenv +``` + +From source: +```bash +git clone https://github.com/ropeadope62/zeroenv +cd zeroenv +pip install -e . +``` + +## Usage + +### Initialize + +```bash +zeroenv init # Standard tier (fast, development) +zeroenv init --tier enhanced # Enhanced security (100k iterations) +zeroenv init --tier max # Maximum security (500k iterations, production) +``` + +Creates `.secrets` (encrypted) and `.secrets.key` (master key). Automatically updates `.gitignore`. + +**Security Tiers:** +- **Standard**: Direct key usage, fastest performance (development) +- **Enhanced**: PBKDF2-SHA256 with 100k iterations (balanced security/performance) +- **Max**: PBKDF2-SHA256 with 500k iterations (production environments) + +### Add Secrets + +```bash +zeroenv add DATABASE_URL "postgresql://localhost/mydb" +zeroenv add API_KEY "sk_test_1234567890" +``` + +### Run Commands + +```bash +zeroenv run python app.py +zeroenv run npm start +zeroenv run dotnet run +``` + +Secrets are injected as environment variables. Your code remains unchanged: + +```python +import os +api_key = os.getenv('API_KEY') +``` + +### List Secrets + +```bash +zeroenv ls # Names only +zeroenv ls --values # Show values +``` + +### Get Secret + +```bash +zeroenv get API_KEY +``` + +### Export + +```bash +zeroenv export # .env format +zeroenv export > .env # Save to file +zeroenv export -f json # JSON format +``` + +### Remove Secret + +```bash +zeroenv rm SECRET_NAME +``` + +### View Configuration + +```bash +zeroenv info +``` + +Displays: +- Security tier and description +- PBKDF2 iteration count +- Encryption algorithm (AES-256-GCM) +- Number of secrets stored +- File locations + +## How It Works + +### Two-File System + +**`.secrets`** - Encrypted secrets (safe to commit) +```json +{ + "version": "1.0", + "security_tier": "enhanced", + "salt": "xY9zA1bC2dE3fG4h...", + "secrets": { + "API_KEY": { + "ciphertext": "aB3dE5fG7hI9jK1...", + "nonce": "pQ6rS8tU0vW1xY2z..." + } + } +} +``` + +**`.secrets.key`** - Master key (never commit, auto-gitignored) +``` +aB3dE5fG7hI9jK1lM2nO4pQ6rS8tU0vW1xY2zA3bC4dE5f= +``` + +### Encryption + +- **Algorithm:** AES-256-GCM +- **Key size:** 256 bits +- **Nonce size:** 96 bits (unique per secret) +- **Authenticated encryption:** Tamper-proof +- **Key derivation:** PBKDF2-HMAC-SHA256 (enhanced/max tiers) +- **KDF iterations:** 100k (enhanced) or 500k (max) + +The encryption process varies by security tier: +- **Standard:** Master key → AES-256-GCM encryption +- **Enhanced/Max:** Master key + salt → PBKDF2 → Derived key → AES-256-GCM encryption + +## Team Usage + +While ZeroEnv was designed with individual developers in mind, usage in small teams can be achieved by sharing the master key. + +### Setup + +```bash +# Developer 1 +zeroenv init +zeroenv add API_KEY "shared-key" +git add .secrets .gitignore +git commit -m "Add encrypted secrets" +git push +``` + +### Join Team + +```bash +# Developer 2 +git clone repo +cd repo +echo "MASTER_KEY" > .secrets.key # Received via secure channel +zeroenv ls # Verify +``` + +Encrypted `.secrets` syncs via git. Master key shared once through secure channel (password manager, encrypted messaging). + +## CI/CD + +Set `ZEROENV_MASTER_KEY` environment variable: + +### GitHub Actions +```yaml +- run: zeroenv run pytest + env: + ZEROENV_MASTER_KEY: ${{ secrets.ZEROENV_MASTER_KEY }} +``` + +### GitLab CI +```yaml +test: + script: + - zeroenv run pytest +``` + +### Azure Pipelines + +```yaml +- script: zeroenv run pytest + env: + ZEROENV_MASTER_KEY: $(ZEROENV_MASTER_KEY) +``` +## Containerization / Docker + +ZeroEnv works seamlessly with Docker. You can inject secrets into your container at runtime without baking them into the image. + +### Dockerfile + +Install `zeroenv` in your image: + +```dockerfile +FROM python:3.9-slim + +WORKDIR /app +COPY . . +RUN pip install zeroenv +RUN pip install -r requirements.txt + +# Don't set entrypoint here if you want to use zeroenv at runtime +CMD ["python", "app.py"] +``` + +### Docker Compose + +Mount the `.secrets` file and the master key (via environment variable): + +```yaml +version: '3' +services: + web: + build: . + volumes: + - ./.secrets:/app/.secrets:ro # Mount secrets file read-only + environment: + - ZEROENV_MASTER_KEY=${ZEROENV_MASTER_KEY} # Pass key from host + command: zeroenv run python app.py +``` + +### Running with Docker + +1. Export the master key on your host: + ```bash + export ZEROENV_MASTER_KEY=$(cat .secrets.key) + ``` + +2. Run the container: + ```bash + docker-compose up + ``` +``` + +## Examples + +### Basic Usage + +```bash +# Initialize with enhanced security +zeroenv init --tier enhanced + +# Check configuration +zeroenv info +# Output: +# ╭─────────────── ZeroEnv Configuration ───────────────╮ +# │ Security Tier: Enhanced │ +# │ PBKDF2 Iterations: 100,000 iterations │ +# │ Encryption: AES-256-GCM │ +# │ Secrets Stored: 0 │ +# ╰─────────────────────────────────────────────────────╯ + +# Add secrets +zeroenv add API_KEY "sk_test_1234" +zeroenv add DATABASE_URL "postgresql://localhost/db" + +# Verify +zeroenv ls --values +``` + +### Django +```bash +zeroenv init +zeroenv add SECRET_KEY "django-key" +zeroenv add DATABASE_URL "postgresql://..." +zeroenv run python manage.py runserver +``` + +### Node.js +```bash +zeroenv init +zeroenv add PORT "3000" +zeroenv add MONGODB_URI "mongodb://..." +zeroenv run npm start +``` + +### .NET +```bash +zeroenv init +zeroenv add ConnectionStrings__Default "Server=..." +zeroenv run dotnet run +``` + +## Security + +### Encryption Details + +- **Algorithm:** AES-256-GCM (AEAD) +- **Key derivation:** 256-bit random key via `os.urandom()` +- **Nonce generation:** 96-bit random nonce per encryption +- **Library:** Python `cryptography` package + +### Security Tiers + +ZeroEnv offers three security tiers to balance performance and security: + +#### Standard (Default) +- **Iterations:** 0 (direct key usage) +- **Performance:** Fastest +- **Use case:** Development, local testing +- **Security:** Strong encryption with AES-256-GCM + +```bash +zeroenv init # or explicitly: zeroenv init --tier standard +``` + +#### Enhanced +- **Iterations:** 100,000 (PBKDF2-HMAC-SHA256) +- **Performance:** Moderate (~100ms key derivation) +- **Use case:** Shared projects, small teams +- **Security:** Resistant to brute-force attacks on weak keys + +```bash +zeroenv init --tier enhanced +``` + +#### Max +- **Iterations:** 500,000 (PBKDF2-HMAC-SHA256) +- **Performance:** Slower (~500ms key derivation) +- **Use case:** Production environments, sensitive data +- **Security:** Maximum protection against brute-force attacks + +```bash +zeroenv init --tier max +``` + +**Note:** Security tier is set during initialization and stored in `.secrets`. All subsequent operations (add, get, run, export) automatically use the configured tier. Existing secrets files without a tier default to `standard` for backward compatibility. +- **Nonce generation:** 96-bit random nonce per encryption +- **Library:** Python `cryptography` package + +## Command Reference + +| Command | Description | +|---------|-------------| +| `init [--tier TIER]` | Initialize ZeroEnv in project (standard/enhanced/max) | +| `add NAME [VALUE]` | Add or update secret | +| `get NAME` | Retrieve secret value | +| `ls [--values]` | List secrets | +| `rm NAME` | Remove secret | +| `run COMMAND` | Run command with secrets | +| `export [-f FORMAT]` | Export secrets | +| `info` | Display security tier and configuration | + +## Dependencies + +- Python 3.10+ +- `click` - CLI framework +- `rich` - Terminal formatting +- `cryptography` - AES-256-GCM implementation + +## Testing + +This directory contains comprehensive unit tests to ensure reliability and security. + +## Why Testing is Critical + +I have added some unit tests to validate the core functionality of ZeroEnv, including the cryptography and storage, Data Integrity, CLI Reliability, Edge Case Handling, Regression Prevention, and Cross-Platform Compatibility. + +## Prerequisites + +The tests require `pytest` and `click`. You can install the development dependencies with: + +```bash +pip install pytest click cryptography rich +``` + +## Running Tests + +To run all tests, execute the following command from the project root: + +```bash +python -m pytest tests +``` + +To run tests with verbose output: + +```bash +python -m pytest tests -v +``` + +To run with coverage report: + +```bash +python -m pytest tests --cov=zeroenv --cov-report=html +``` + +## Test Structure + +### `test_crypto.py` - Cryptographic Operations +Tests the core security layer: +- **Key Generation**: Validates 256-bit keys are properly generated +- **Encryption**: Ensures plaintext is encrypted correctly with unique nonces +- **Decryption**: Verifies encrypted data can be decrypted back to original +- **Key Encoding**: Tests base64 encoding/decoding of keys +- **Invalid Inputs**: Tests behavior with malformed data or wrong keys +- **Nonce Uniqueness**: Ensures each encryption uses a unique nonce + +**Critical Tests:** +- Different values produce different ciphertexts +- Same value encrypted twice produces different ciphertexts (due to unique nonces) +- Decryption with wrong key fails properly +- Tampered ciphertext is detected + +### `test_storage.py` - File Operations & Data Management +Tests the persistence layer: +- **Initialization**: Creates `.secrets` and `.secrets.key` files correctly +- **File Integrity**: Validates JSON structure and metadata +- **Secret CRUD**: Add, retrieve, update, delete secrets +- **Key Loading**: Properly loads master key from file +- **Error Handling**: Missing files, corrupted data, permission errors +- **Metadata**: Timestamps and versioning work correctly + +**Critical Tests:** +- Secrets persist across program restarts +- Multiple secrets don't interfere with each other +- File permissions are appropriate +- Corrupted `.secrets` file is handled gracefully + +### `test_cli.py` - Command Line Interface +Tests the user-facing commands: +- **Init Command**: Creates files and updates .gitignore +- **Add Command**: Interactive and direct modes work +- **Get Command**: Retrieves secrets correctly +- **List Command**: Shows secrets with/without values +- **Remove Command**: Deletes secrets with confirmation +- **Run Command**: Injects environment variables properly +- **Export Command**: Outputs in .env and JSON formats +- **Error Messages**: Helpful errors for common mistakes + +**Critical Tests:** +- `zeroenv init` in already-initialized directory fails gracefully +- `zeroenv add` without init shows helpful error +- Environment variables are properly injected for `run` +- Confirmation prompts work for destructive operations +- Exit codes are correct (0 for success, 1 for errors) + +## Troubleshooting + +If you encounter issues with `test_cli.py` failing to capture output, ensure you are running the tests from the project root directory. + +**Common Issues:** +- **Import errors**: Run `pip install -e .` from project root +- **File permission errors**: Tests create temporary directories - ensure write permissions +- **Flaky tests**: Some CLI tests may be environment-dependent - use `pytest-xdist` for isolation + + +## Development + +```bash +git clone https://github.com/ropeadope62/zeroenv +cd zeroenv +pip install -e ".[dev]" +pytest +``` + +## License + +MIT + +## Credits + +Built by Dave C. (ropeadope62) +[artoffailing.com](https://artoffailing.com) + +--- + +> GitHub [@ropeadope62](https://github.com/ropeadope62) diff --git a/src/zeroenv.egg-info/SOURCES.txt b/src/zeroenv.egg-info/SOURCES.txt new file mode 100644 index 0000000..b33d09e --- /dev/null +++ b/src/zeroenv.egg-info/SOURCES.txt @@ -0,0 +1,17 @@ +LICENSE +README.md +pyproject.toml +src/zeroenv/__init__.py +src/zeroenv/cli.py +src/zeroenv/crypto.py +src/zeroenv/storage.py +src/zeroenv/ui.py +src/zeroenv.egg-info/PKG-INFO +src/zeroenv.egg-info/SOURCES.txt +src/zeroenv.egg-info/dependency_links.txt +src/zeroenv.egg-info/entry_points.txt +src/zeroenv.egg-info/requires.txt +src/zeroenv.egg-info/top_level.txt +tests/test_cli.py +tests/test_crypto.py +tests/test_storage.py \ No newline at end of file diff --git a/src/zeroenv.egg-info/dependency_links.txt b/src/zeroenv.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/zeroenv.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/zeroenv.egg-info/entry_points.txt b/src/zeroenv.egg-info/entry_points.txt new file mode 100644 index 0000000..32a98f3 --- /dev/null +++ b/src/zeroenv.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +zeroenv = zeroenv.cli:main diff --git a/src/zeroenv.egg-info/requires.txt b/src/zeroenv.egg-info/requires.txt new file mode 100644 index 0000000..02decdf --- /dev/null +++ b/src/zeroenv.egg-info/requires.txt @@ -0,0 +1,3 @@ +click +cryptography +rich diff --git a/src/zeroenv.egg-info/top_level.txt b/src/zeroenv.egg-info/top_level.txt new file mode 100644 index 0000000..98bcf33 --- /dev/null +++ b/src/zeroenv.egg-info/top_level.txt @@ -0,0 +1 @@ +zeroenv diff --git a/src/zeroenv/__pycache__/__init__.cpython-312.pyc b/src/zeroenv/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..035cf6e Binary files /dev/null and b/src/zeroenv/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/zeroenv/__pycache__/crypto.cpython-312.pyc b/src/zeroenv/__pycache__/crypto.cpython-312.pyc new file mode 100644 index 0000000..3294faa Binary files /dev/null and b/src/zeroenv/__pycache__/crypto.cpython-312.pyc differ diff --git a/src/zeroenv/crypto.py b/src/zeroenv/crypto.py index 9f168c6..8c7a911 100644 --- a/src/zeroenv/crypto.py +++ b/src/zeroenv/crypto.py @@ -7,12 +7,20 @@ import os import base64 +from enum import Enum from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.backends import default_backend +class SecurityTier(str, Enum): + """Security tier for PBKDF2 key derivation""" + standard = 'standard' + enhanced = 'enhanced' + max = 'max' + + class ZeroEnvCrypto: """Handles all cryptographic operations for ZeroEnv""" @@ -48,17 +56,33 @@ class ZeroEnvCrypto: SALT_SIZE = 16 # 128 bits for PBKDF2 salt - def __init__(self, master_key: bytes): + def __init__(self, master_key: bytes, tier: "str | SecurityTier" = 'standard', salt: bytes = None): """ Initialize crypto with master key - + Args: master_key: 32-byte encryption key + tier: Security tier ('standard', 'enhanced', or 'max'), or SecurityTier enum value + salt: Salt for PBKDF2 key derivation (required for non-standard tiers) """ if len(master_key) != self.KEY_SIZE: raise ValueError(f"Master key must be {self.KEY_SIZE} bytes") - - self.aesgcm = AESGCM(master_key) + + self._master_key = master_key + self._tier = tier.value if isinstance(tier, SecurityTier) else tier + self._salt = salt + + encryption_key = self._derive_key() + self.aesgcm = AESGCM(encryption_key) + + def _derive_key(self) -> bytes: + """ + Derive encryption key from master key using configured security tier + + Returns: + Derived key bytes (same as master key for standard tier) + """ + return ZeroEnvCrypto.derive_key(self._master_key, self._tier, self._salt) def encrypt(self, plaintext: str) -> dict: """ @@ -195,6 +219,32 @@ def string_to_key(key_string: str) -> bytes: """ return base64.b64decode(key_string) + @staticmethod + def salt_to_string(salt: bytes) -> str: + """ + Convert salt bytes to base64 string for storage + + Args: + salt: Salt bytes + + Returns: + Base64-encoded salt string + """ + return base64.b64encode(salt).decode('utf-8') + + @staticmethod + def string_to_salt(salt_string: str) -> bytes: + """ + Convert base64 salt string back to bytes + + Args: + salt_string: Base64-encoded salt + + Returns: + Salt bytes + """ + return base64.b64decode(salt_string) + def generate_master_key() -> bytes: """ diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7d2d97f Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/test_crypto.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_crypto.cpython-312-pytest-9.0.3.pyc new file mode 100644 index 0000000..00189af Binary files /dev/null and b/tests/__pycache__/test_crypto.cpython-312-pytest-9.0.3.pyc differ diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 3a53ce1..6b115c7 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -9,7 +9,7 @@ import pytest import base64 import os -from zeroenv.crypto import ZeroEnvCrypto, generate_master_key +from zeroenv.crypto import ZeroEnvCrypto, SecurityTier, generate_master_key class TestZeroEnvCrypto: @pytest.fixture @@ -186,3 +186,143 @@ def test_encrypt_decrypt_with_derived_key(self, master_key): decrypted = crypto.decrypt(encrypted) assert decrypted == plaintext + + +class TestSecurityTierEnum: + """Tests for the SecurityTier enum""" + + def test_enum_values(self): + """Test that SecurityTier enum has the correct values""" + assert SecurityTier.standard == 'standard' + assert SecurityTier.enhanced == 'enhanced' + assert SecurityTier.max == 'max' + + def test_enum_members(self): + """Test that all expected members exist""" + members = {tier.value for tier in SecurityTier} + assert members == {'standard', 'enhanced', 'max'} + + def test_enum_is_string(self): + """Test that SecurityTier values behave as strings""" + assert isinstance(SecurityTier.standard, str) + assert SecurityTier.enhanced == 'enhanced' + + def test_enum_from_value(self): + """Test creating enum from string value""" + assert SecurityTier('standard') is SecurityTier.standard + assert SecurityTier('enhanced') is SecurityTier.enhanced + assert SecurityTier('max') is SecurityTier.max + + +class TestZeroEnvCryptoInitWithTier: + """Tests for ZeroEnvCrypto.__init__ with tier and salt parameters""" + + @pytest.fixture + def master_key(self): + return generate_master_key() + + def test_init_standard_tier_default(self, master_key): + """Test that standard tier (default) uses key directly (no PBKDF2)""" + crypto_default = ZeroEnvCrypto(master_key) + crypto_explicit = ZeroEnvCrypto(master_key, tier='standard') + # Both should encrypt/decrypt the same data interchangeably + plaintext = "standard_secret" + encrypted = crypto_default.encrypt(plaintext) + assert crypto_explicit.decrypt(encrypted) == plaintext + + def test_init_standard_tier_explicit(self, master_key): + """Test explicit standard tier initialization""" + crypto = ZeroEnvCrypto(master_key, tier='standard') + assert crypto.decrypt(crypto.encrypt("test")) == "test" + + def test_init_enhanced_tier_with_salt(self, master_key): + """Test enhanced tier initialization with salt""" + salt = ZeroEnvCrypto.generate_salt() + crypto = ZeroEnvCrypto(master_key, tier='enhanced', salt=salt) + # Should be able to encrypt and decrypt + plaintext = "enhanced_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_init_max_tier_with_salt(self, master_key): + """Test max tier initialization with salt""" + salt = ZeroEnvCrypto.generate_salt() + crypto = ZeroEnvCrypto(master_key, tier='max', salt=salt) + # Should be able to encrypt and decrypt + plaintext = "max_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_init_enhanced_tier_missing_salt_raises(self, master_key): + """Test that enhanced tier without salt raises ValueError""" + with pytest.raises(ValueError, match="Salt required"): + ZeroEnvCrypto(master_key, tier='enhanced', salt=None) + + def test_init_max_tier_missing_salt_raises(self, master_key): + """Test that max tier without salt raises ValueError""" + with pytest.raises(ValueError, match="Salt required"): + ZeroEnvCrypto(master_key, tier='max', salt=None) + + def test_init_with_security_tier_enum(self, master_key): + """Test that SecurityTier enum values work in __init__""" + salt = ZeroEnvCrypto.generate_salt() + crypto = ZeroEnvCrypto(master_key, tier=SecurityTier.enhanced, salt=salt) + plaintext = "enum_tier_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_encrypt_decrypt_standard_via_init(self, master_key): + """Test encrypt/decrypt with standard tier via __init__""" + crypto = ZeroEnvCrypto(master_key, tier='standard') + plaintext = "standard_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_encrypt_decrypt_enhanced_via_init(self, master_key): + """Test encrypt/decrypt with enhanced tier via __init__""" + salt = ZeroEnvCrypto.generate_salt() + crypto = ZeroEnvCrypto(master_key, tier='enhanced', salt=salt) + plaintext = "enhanced_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_encrypt_decrypt_max_via_init(self, master_key): + """Test encrypt/decrypt with max tier via __init__""" + salt = ZeroEnvCrypto.generate_salt() + crypto = ZeroEnvCrypto(master_key, tier='max', salt=salt) + plaintext = "max_secret" + assert crypto.decrypt(crypto.encrypt(plaintext)) == plaintext + + def test_different_tiers_incompatible(self, master_key): + """Test that data encrypted with one tier cannot be decrypted with another""" + salt = ZeroEnvCrypto.generate_salt() + crypto_standard = ZeroEnvCrypto(master_key, tier='standard') + crypto_enhanced = ZeroEnvCrypto(master_key, tier='enhanced', salt=salt) + + encrypted = crypto_standard.encrypt("secret") + with pytest.raises(Exception): + crypto_enhanced.decrypt(encrypted) + + +class TestSaltConversionMethods: + """Tests for salt_to_string and string_to_salt conversion methods""" + + def test_salt_to_string_returns_str(self): + """Test that salt_to_string returns a string""" + salt = ZeroEnvCrypto.generate_salt() + result = ZeroEnvCrypto.salt_to_string(salt) + assert isinstance(result, str) + + def test_string_to_salt_returns_bytes(self): + """Test that string_to_salt returns bytes""" + salt = ZeroEnvCrypto.generate_salt() + salt_str = ZeroEnvCrypto.salt_to_string(salt) + result = ZeroEnvCrypto.string_to_salt(salt_str) + assert isinstance(result, bytes) + + def test_salt_roundtrip(self): + """Test that salt survives a to_string/from_string roundtrip""" + salt = ZeroEnvCrypto.generate_salt() + assert ZeroEnvCrypto.string_to_salt(ZeroEnvCrypto.salt_to_string(salt)) == salt + + def test_salt_to_string_is_valid_base64(self): + """Test that salt_to_string produces valid base64""" + salt = ZeroEnvCrypto.generate_salt() + salt_str = ZeroEnvCrypto.salt_to_string(salt) + decoded = base64.b64decode(salt_str) + assert decoded == salt