"""Property-basierte Tests für SecretEncryptionManager: Verschlüsselung nur mit Kontextschlüssel. **Validates: Requirements 9.3, 9.8** Property 23: Verschlüsselte Secrets können nur mit autorisiertem Kontextschlüssel entschlüsselt werden For any verschlüsselte Secret-Datei eines Arbeitskontexts X und für jeden Entschlüsselungsversuch mit einem Schlüssel des Kontexts Y (wobei X ≠ Y), muss die Entschlüsselung fehlschlagen und die Datei im verschlüsselten Zustand verbleiben. Nur der Schlüssel des zugehörigen Kontexts X darf die Datei erfolgreich entschlüsseln. """ from __future__ import annotations import tempfile from pathlib import Path from unittest.mock import patch from hypothesis import assume, given, settings from hypothesis import strategies as st from monorepo.encryption import SecretEncryptionManager from monorepo.models import MachineContext # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Valid non-shared work contexts for encryption WORK_CONTEXTS = ["privat", "dhive", "bahn"] # Secret file names that match the SECRET_PATTERNS SECRET_FILE_NAMES = [".env", "private.pem", "server.key", "api-token.txt", "my-secret.yaml"] # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- @st.composite def machine_context_with_authorized(draw: st.DrawFn) -> tuple[MachineContext, list[str]]: """Generates a MachineContext with a random subset of authorized contexts. Returns (MachineContext, authorized_contexts_list). Ensures at least one context is authorized. """ # Draw a non-empty subset of contexts authorized = draw( st.lists( st.sampled_from(WORK_CONTEXTS), min_size=1, max_size=3, unique=True, ) ) name = draw(st.sampled_from(["test-rechner", "dhive-laptop", "bahn-pc", "home-pc"])) ctx = MachineContext( name=name, description=f"Test machine: {name}", authorized_contexts=authorized, key_source="keyring", ) return ctx, authorized @st.composite def unauthorized_file_context( draw: st.DrawFn, authorized_contexts: list[str] ) -> str: """Generates a context that is NOT in the authorized list.""" unauthorized = [c for c in WORK_CONTEXTS if c not in authorized_contexts] assume(len(unauthorized) > 0) return draw(st.sampled_from(unauthorized)) @st.composite def secret_file_name(draw: st.DrawFn) -> str: """Generates a secret file name matching common patterns.""" return draw(st.sampled_from(SECRET_FILE_NAMES)) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _create_monorepo_with_secret( tmp_dir: Path, context: str, filename: str, content: bytes ) -> Path: """Creates a minimal monorepo structure with a secret file in the given context. Returns the path to the created secret file. """ # Create context directories for ctx in WORK_CONTEXTS + ["shared"]: (tmp_dir / ctx).mkdir(parents=True, exist_ok=True) # Create the secret file secret_path = tmp_dir / context / filename secret_path.write_bytes(content) return secret_path # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestProperty23EncryptionOnlyWithContextKey: """Property 23: Verschlüsselte Secrets können nur mit autorisiertem Kontextschlüssel entschlüsselt werden. **Validates: Requirements 9.3, 9.8** For any encrypted file belonging to a context, decryption must succeed ONLY when the machine context includes the authorized key for that context. Without the correct key, decryption must fail without revealing file content. """ @given(data=st.data()) @settings(max_examples=200) def test_decrypt_fails_for_unauthorized_context(self, data: st.DataObject) -> None: """Decryption must fail when the machine context does NOT authorize the file's context. For any file in context X, if the machine only authorizes contexts Y (where X not in Y), decrypt_file must return success=False. """ # Generate a machine context with a subset of authorized contexts machine_ctx, authorized = data.draw(machine_context_with_authorized()) # Pick a file context that is NOT authorized unauthorized_ctxs = [c for c in WORK_CONTEXTS if c not in authorized] assume(len(unauthorized_ctxs) > 0) file_context = data.draw(st.sampled_from(unauthorized_ctxs)) # Generate a secret file name filename = data.draw(secret_file_name()) # Setup temporary monorepo tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_")) secret_content = b"SUPER_SECRET_VALUE=mysecret123\nDB_PASSWORD=hunter2" secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content) # Create manager with the generated machine context manager = SecretEncryptionManager(tmp_dir, machine_ctx) # Patch _run_gitcrypt so we don't need actual git-crypt installed with patch.object(manager, "_run_gitcrypt"): result = manager.decrypt_file(secret_path) # Decryption MUST fail for unauthorized context assert result.success is False, ( f"Decryption should fail: machine '{machine_ctx.name}' " f"(authorized: {authorized}) tried to decrypt file in " f"context '{file_context}'" ) # Content must NOT be returned on failure assert result.content is None, ( f"Failed decryption must not return file content. " f"Got content for file in context '{file_context}'" ) @given(data=st.data()) @settings(max_examples=200) def test_decrypt_succeeds_for_authorized_context(self, data: st.DataObject) -> None: """Decryption must succeed when the machine context DOES authorize the file's context. For any file in context X, if the machine authorizes X, decrypt_file must return success=True with the file content. """ # Generate a machine context machine_ctx, authorized = data.draw(machine_context_with_authorized()) # Pick a file context that IS authorized file_context = data.draw(st.sampled_from(authorized)) # Generate a secret file name filename = data.draw(secret_file_name()) # Setup temporary monorepo tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_")) secret_content = b"AUTHORIZED_SECRET=value\nKEY=data" secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content) # Create manager with the generated machine context manager = SecretEncryptionManager(tmp_dir, machine_ctx) # Patch _run_gitcrypt to simulate successful unlock with patch.object(manager, "_run_gitcrypt"): result = manager.decrypt_file(secret_path) # Decryption MUST succeed for authorized context assert result.success is True, ( f"Decryption should succeed: machine '{machine_ctx.name}' " f"(authorized: {authorized}) should decrypt file in " f"context '{file_context}'. Error: {result.error}" ) # Content must be returned on success assert result.content is not None, ( f"Successful decryption must return file content for " f"context '{file_context}'" ) assert result.content == secret_content @given(data=st.data()) @settings(max_examples=200) def test_failed_decryption_does_not_reveal_content(self, data: st.DataObject) -> None: """When decryption fails, the error message must NOT contain the file's actual contents. This ensures that the encryption boundary does not leak sensitive information through error messages (Requirement 9.8). """ # Generate a machine context with limited access machine_ctx, authorized = data.draw(machine_context_with_authorized()) # Pick a file context that is NOT authorized unauthorized_ctxs = [c for c in WORK_CONTEXTS if c not in authorized] assume(len(unauthorized_ctxs) > 0) file_context = data.draw(st.sampled_from(unauthorized_ctxs)) filename = data.draw(secret_file_name()) # Use recognizable secret content to check for leakage secret_content = b"TOP_SECRET_API_KEY=sk-abc123xyz789\nDATABASE_URL=postgres://admin:pass@host/db" secret_strings = [ "TOP_SECRET_API_KEY", "sk-abc123xyz789", "DATABASE_URL", "postgres://admin:pass@host/db", ] tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_")) secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content) manager = SecretEncryptionManager(tmp_dir, machine_ctx) with patch.object(manager, "_run_gitcrypt"): result = manager.decrypt_file(secret_path) # Must fail assert result.success is False # Error message must NOT contain any of the secret content error_msg = result.error or "" for secret_str in secret_strings: assert secret_str not in error_msg, ( f"Error message leaks file content! " f"Found '{secret_str}' in error: '{error_msg}'" ) @given(data=st.data()) @settings(max_examples=200) def test_is_authorized_returns_true_only_for_authorized_contexts( self, data: st.DataObject ) -> None: """is_authorized() must return True ONLY for contexts in the machine's authorized_contexts list, and False for all others. This is the fundamental gate that controls decryption access. """ # Generate a machine context machine_ctx, authorized = data.draw(machine_context_with_authorized()) # Pick any context to check check_context = data.draw(st.sampled_from(WORK_CONTEXTS)) tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_")) for ctx in WORK_CONTEXTS + ["shared"]: (tmp_dir / ctx).mkdir(parents=True, exist_ok=True) manager = SecretEncryptionManager(tmp_dir, machine_ctx) result = manager.is_authorized(check_context) if check_context in authorized: assert result is True, ( f"is_authorized('{check_context}') should be True " f"for machine with authorized={authorized}" ) else: assert result is False, ( f"is_authorized('{check_context}') should be False " f"for machine with authorized={authorized}" )