293 lines
11 KiB
Python
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}"
|
|
)
|