Files
the-other-dude/backend/app/services/crypto.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

184 lines
6.2 KiB
Python

"""
Credential encryption/decryption with dual-read (OpenBao Transit + legacy AES-256-GCM).
This module provides two encryption paths:
1. Legacy (sync): AES-256-GCM with static CREDENTIAL_ENCRYPTION_KEY — used for fallback reads.
2. Transit (async): OpenBao Transit per-tenant keys — used for all new writes.
The dual-read pattern:
- New writes always use OpenBao Transit (encrypt_credentials_transit).
- Reads prefer Transit ciphertext, falling back to legacy (decrypt_credentials_hybrid).
- Legacy functions are preserved for backward compatibility during migration.
Security properties:
- AES-256-GCM provides authenticated encryption (confidentiality + integrity)
- A unique 12-byte random nonce is generated per legacy encryption operation
- OpenBao Transit keys are AES-256-GCM96, managed entirely by OpenBao
- Ciphertext format: "vault:v1:..." for Transit, raw bytes for legacy
"""
import os
def encrypt_credentials(plaintext: str, key: bytes) -> bytes:
"""
Encrypt a plaintext string using AES-256-GCM.
Args:
plaintext: The credential string to encrypt (e.g., JSON with username/password)
key: 32-byte encryption key
Returns:
bytes: nonce (12 bytes) + ciphertext + GCM tag (16 bytes)
Raises:
ValueError: If key is not exactly 32 bytes
"""
if len(key) != 32:
raise ValueError(f"Key must be exactly 32 bytes, got {len(key)}")
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce, unique per encryption
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
# Store as: nonce (12 bytes) + ciphertext + GCM tag (included in ciphertext by library)
return nonce + ciphertext
def decrypt_credentials(ciphertext: bytes, key: bytes) -> str:
"""
Decrypt AES-256-GCM encrypted credentials.
Args:
ciphertext: bytes from encrypt_credentials (nonce + encrypted data + GCM tag)
key: 32-byte encryption key (must match the key used for encryption)
Returns:
str: The original plaintext string
Raises:
ValueError: If key is not exactly 32 bytes
cryptography.exceptions.InvalidTag: If authentication fails (tampered data or wrong key)
"""
if len(key) != 32:
raise ValueError(f"Key must be exactly 32 bytes, got {len(key)}")
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce = ciphertext[:12]
encrypted_data = ciphertext[12:]
aesgcm = AESGCM(key)
plaintext_bytes = aesgcm.decrypt(nonce, encrypted_data, None)
return plaintext_bytes.decode("utf-8")
# ---------------------------------------------------------------------------
# OpenBao Transit functions (async, per-tenant keys)
# ---------------------------------------------------------------------------
async def encrypt_credentials_transit(plaintext: str, tenant_id: str) -> str:
"""Encrypt via OpenBao Transit. Returns ciphertext string (vault:v1:...).
Args:
plaintext: The credential string to encrypt.
tenant_id: Tenant UUID string for key lookup.
Returns:
Transit ciphertext string (vault:v1:base64...).
"""
from app.services.openbao_service import get_openbao_service
service = get_openbao_service()
return await service.encrypt(tenant_id, plaintext.encode("utf-8"))
async def decrypt_credentials_transit(ciphertext: str, tenant_id: str) -> str:
"""Decrypt OpenBao Transit ciphertext. Returns plaintext string.
Args:
ciphertext: Transit ciphertext (vault:v1:...).
tenant_id: Tenant UUID string for key lookup.
Returns:
Decrypted plaintext string.
"""
from app.services.openbao_service import get_openbao_service
service = get_openbao_service()
plaintext_bytes = await service.decrypt(tenant_id, ciphertext)
return plaintext_bytes.decode("utf-8")
# ---------------------------------------------------------------------------
# OpenBao Transit data encryption (async, per-tenant _data keys — Phase 30)
# ---------------------------------------------------------------------------
async def encrypt_data_transit(plaintext: str, tenant_id: str) -> str:
"""Encrypt non-credential data via OpenBao Transit using per-tenant data key.
Used for audit log details, config backups, and reports. Data keys are
separate from credential keys (tenant_{uuid}_data vs tenant_{uuid}).
Args:
plaintext: The data string to encrypt.
tenant_id: Tenant UUID string for data key lookup.
Returns:
Transit ciphertext string (vault:v1:base64...).
"""
from app.services.openbao_service import get_openbao_service
service = get_openbao_service()
return await service.encrypt_data(tenant_id, plaintext.encode("utf-8"))
async def decrypt_data_transit(ciphertext: str, tenant_id: str) -> str:
"""Decrypt OpenBao Transit data ciphertext. Returns plaintext string.
Args:
ciphertext: Transit ciphertext (vault:v1:...).
tenant_id: Tenant UUID string for data key lookup.
Returns:
Decrypted plaintext string.
"""
from app.services.openbao_service import get_openbao_service
service = get_openbao_service()
plaintext_bytes = await service.decrypt_data(tenant_id, ciphertext)
return plaintext_bytes.decode("utf-8")
async def decrypt_credentials_hybrid(
transit_ciphertext: str | None,
legacy_ciphertext: bytes | None,
tenant_id: str,
legacy_key: bytes,
) -> str:
"""Dual-read: prefer Transit ciphertext, fall back to legacy.
Args:
transit_ciphertext: OpenBao Transit ciphertext (vault:v1:...) or None.
legacy_ciphertext: Legacy AES-256-GCM bytes (nonce+ciphertext+tag) or None.
tenant_id: Tenant UUID string for Transit key lookup.
legacy_key: 32-byte legacy encryption key for fallback.
Returns:
Decrypted plaintext string.
Raises:
ValueError: If neither ciphertext is available.
"""
if transit_ciphertext and transit_ciphertext.startswith("vault:v"):
return await decrypt_credentials_transit(transit_ciphertext, tenant_id)
elif legacy_ciphertext:
return decrypt_credentials(legacy_ciphertext, legacy_key)
else:
raise ValueError("No credentials available (both transit and legacy are empty)")