Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"""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}"
|
||||
)
|
||||
Reference in New Issue
Block a user