Encrypt sensitive credential fields before event bus emission (#215)
- Add encrypt_credential_dict() and decrypt_credential_field() to utils/crypto.py - Create utils/credential_encryption.py with encryption/decryption helpers - Add get_credential_encryption_key() to retrieve key from Infisical at runtime - Add emit_credential_found() wrapper for modules to use instead of direct bus.emit() - Update all 15 modules emitting CREDENTIAL_FOUND to use encrypted wrapper - Update 4 modules consuming CREDENTIAL_FOUND to decrypt payload before processing - Sensitive fields (username, password, hash, token, etc.) encrypted with AES-256-GCM - Falls back to plaintext if encryption key unavailable or encryption fails
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helper to get encryption key from Infisical and encrypt credentials before emission."""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("sensor.credential_encryption")
|
||||
|
||||
# Cache the key in memory during module lifetime
|
||||
_cached_key: Optional[bytes] = None
|
||||
|
||||
def get_credential_encryption_key() -> Optional[bytes]:
|
||||
"""Retrieve credential encryption key from Infisical via creds CLI.
|
||||
|
||||
Returns None if key cannot be retrieved (in which case plaintext fallback).
|
||||
Caches key in memory for the lifetime of the module process.
|
||||
"""
|
||||
global _cached_key
|
||||
if _cached_key is not None:
|
||||
return _cached_key
|
||||
|
||||
try:
|
||||
# Try to get CREDENTIAL_ENCRYPTION_KEY from Infisical via creds CLI
|
||||
result = subprocess.run(
|
||||
["/home/n0mad1k/.local/bin/creds", "get", "CREDENTIAL_ENCRYPTION_KEY"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning("Failed to retrieve CREDENTIAL_ENCRYPTION_KEY from Infisical: %s", result.stderr)
|
||||
return None
|
||||
|
||||
key_hex = result.stdout.strip()
|
||||
if not key_hex:
|
||||
logger.warning("CREDENTIAL_ENCRYPTION_KEY is empty in Infisical")
|
||||
return None
|
||||
|
||||
# Convert hex string to bytes
|
||||
_cached_key = bytes.fromhex(key_hex)
|
||||
if len(_cached_key) != 32:
|
||||
logger.error("CREDENTIAL_ENCRYPTION_KEY must be 32 bytes (256-bit), got %d", len(_cached_key))
|
||||
_cached_key = None
|
||||
return None
|
||||
|
||||
logger.debug("Successfully loaded CREDENTIAL_ENCRYPTION_KEY from Infisical")
|
||||
return _cached_key
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("creds CLI not found at ~/.local/bin/creds")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Timeout retrieving CREDENTIAL_ENCRYPTION_KEY from Infisical")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error retrieving CREDENTIAL_ENCRYPTION_KEY: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def emit_credential_found(bus, source_module: str, payload: dict) -> None:
|
||||
"""Emit CREDENTIAL_FOUND event with encrypted sensitive fields.
|
||||
|
||||
Sensitive fields are encrypted before emission. If encryption fails,
|
||||
plaintext fallback is used with a warning logged.
|
||||
|
||||
Args:
|
||||
bus: EventBus instance
|
||||
source_module: Module name emitting the event
|
||||
payload: Credential payload dict with fields like username, password, etc.
|
||||
"""
|
||||
from utils.crypto import encrypt_credential_dict
|
||||
|
||||
key = get_credential_encryption_key()
|
||||
|
||||
if key:
|
||||
try:
|
||||
encrypted_payload = encrypt_credential_dict(payload, key)
|
||||
bus.emit("CREDENTIAL_FOUND", encrypted_payload, source_module=source_module)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Failed to encrypt credential payload: %s — using plaintext fallback", e)
|
||||
|
||||
# Fallback: emit plaintext with warning
|
||||
bus.emit("CREDENTIAL_FOUND", payload, source_module=source_module)
|
||||
|
||||
|
||||
def decrypt_credential_payload(payload: dict) -> dict:
|
||||
"""Decrypt sensitive fields in credential event payload.
|
||||
|
||||
Looks for fields marked with __encrypted__ prefix and decrypts them.
|
||||
Returns a new dict with decrypted fields.
|
||||
|
||||
Args:
|
||||
payload: Credential dict with possibly encrypted fields
|
||||
|
||||
Returns:
|
||||
New dict with decrypted values
|
||||
"""
|
||||
from utils.crypto import decrypt_credential_field
|
||||
|
||||
key = get_credential_encryption_key()
|
||||
if not key:
|
||||
return payload
|
||||
|
||||
decrypted = {}
|
||||
|
||||
for field, value in payload.items():
|
||||
if isinstance(value, str) and value.startswith("__encrypted__"):
|
||||
try:
|
||||
decrypted[field] = decrypt_credential_field(value, key)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to decrypt field %s: %s", field, e)
|
||||
decrypted[field] = value
|
||||
else:
|
||||
decrypted[field] = value
|
||||
|
||||
return decrypted
|
||||
@@ -73,6 +73,65 @@ class CryptoEngine:
|
||||
return self._aesgcm.decrypt(nonce, ct, aad)
|
||||
|
||||
|
||||
|
||||
|
||||
def encrypt_credential_dict(payload: dict, key: bytes) -> dict:
|
||||
"""Encrypt sensitive fields in credential event payload before bus emission.
|
||||
|
||||
Sensitive fields: username, password, hash, domain, token, secret, api_key
|
||||
Returns a new dict with encrypted fields as bytes wrapped in __encrypted__ markers.
|
||||
"""
|
||||
if len(key) != KEY_SIZE:
|
||||
raise ValueError(f"Key must be {KEY_SIZE} bytes")
|
||||
|
||||
engine = CryptoEngine(key)
|
||||
encrypted = {}
|
||||
|
||||
sensitive_fields = {
|
||||
"username", "password", "hash", "hash_value", "domain",
|
||||
"token", "secret", "api_key", "access_token", "refresh_token",
|
||||
"credential_value", "nt_hash", "lm_hash", "response",
|
||||
"auth_string", "bearer_token"
|
||||
}
|
||||
|
||||
for field, value in payload.items():
|
||||
if field.lower() in sensitive_fields and isinstance(value, str) and value:
|
||||
try:
|
||||
# Encrypt the string value
|
||||
plaintext = value.encode("utf-8")
|
||||
ciphertext = engine.encrypt(plaintext)
|
||||
# Encode to base64 for transport over JSON
|
||||
import base64
|
||||
encrypted[field] = f"__encrypted__{base64.b64encode(ciphertext).decode('ascii')}"
|
||||
except Exception:
|
||||
# If encryption fails, include plaintext (log warning in caller)
|
||||
encrypted[field] = value
|
||||
else:
|
||||
# Pass through unencrypted
|
||||
encrypted[field] = value
|
||||
|
||||
return encrypted
|
||||
|
||||
|
||||
def decrypt_credential_field(value: str, key: bytes) -> str:
|
||||
"""Decrypt a single encrypted credential field."""
|
||||
if not isinstance(value, str) or not value.startswith("__encrypted__"):
|
||||
return value
|
||||
|
||||
if len(key) != KEY_SIZE:
|
||||
raise ValueError(f"Key must be {KEY_SIZE} bytes")
|
||||
|
||||
try:
|
||||
import base64
|
||||
engine = CryptoEngine(key)
|
||||
encrypted_part = value[len("__encrypted__"):]
|
||||
ciphertext = base64.b64decode(encrypted_part)
|
||||
plaintext = engine.decrypt(ciphertext)
|
||||
return plaintext.decode("utf-8")
|
||||
except Exception:
|
||||
# Return original if decryption fails
|
||||
return value
|
||||
|
||||
def encrypt_file(
|
||||
src: str,
|
||||
dst: str,
|
||||
|
||||
Reference in New Issue
Block a user