Files
Orchestrator/shared/tools/monorepo-cli/tests/test_encryption_properties.py
2026-06-30 20:37:40 +02:00

293 lines
11 KiB
Python

"""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}"
)