Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / app / ai / vault.py 3505 B · main
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"""Credential vault: a Fernet symmetric key generated once from Admin ->
Vault and wrapped (encrypted) with the deployment's SECRET_KEY before being
stored on the Tenant row, so a database leak alone never exposes it - an
attacker would also need SECRET_KEY. There is deliberately no rotate or
recreate path anywhere in this module or the API surface above it: rotating
would make every already-vaulted credential (and the stored Claude API key)
permanently undecryptable, so create_vault() raises if a vault already
exists rather than overwriting it.

encrypt_secret/decrypt_secret both take the tenant row directly (not a bare
key) so they can raise a clear, specific VaultError when the vault hasn't
been created yet, rather than a confusing Fernet decode error. Decryption
happens exclusively inside the remediation execution path, in-memory,
immediately before dispatching a command - no API response ever returns a
decrypted secret, the encrypted blob, or the wrapped vault key itself.
"""
import base64
import hashlib
import os
from datetime import datetime, timezone

from cryptography.fernet import Fernet, InvalidToken


class VaultError(RuntimeError):
    pass


class VaultAlreadyExistsError(VaultError):
    pass


def _wrapping_fernet() -> Fernet:
    """Derives a stable Fernet key from SECRET_KEY to wrap the real vault
    key at rest. Using SECRET_KEY (already a required, deployment-wide
    secret used for session signing) means protecting the vault key needs
    no new environment variable of its own."""
    secret_key = os.getenv("SECRET_KEY", "change-me-in-production")
    digest = hashlib.sha256(secret_key.encode()).digest()
    return Fernet(base64.urlsafe_b64encode(digest))


def create_vault(tenant) -> str:
    """Generates a fresh Fernet key, wraps it with the SECRET_KEY-derived
    wrapping key, and stores it on the tenant row. Returns the *raw*
    (unwrapped) key so the caller can show it to the admin exactly once -
    it is never recoverable from the API again after this call returns.
    Raises VaultAlreadyExistsError if this tenant already has a vault;
    there is no update path, by design. Caller is responsible for
    committing the session."""
    if tenant.vault_key_wrapped:
        raise VaultAlreadyExistsError("A vault already exists for this tenant and cannot be recreated.")

    raw_key = Fernet.generate_key()
    tenant.vault_key_wrapped = _wrapping_fernet().encrypt(raw_key).decode()
    tenant.vault_created_at = datetime.now(timezone.utc)
    return raw_key.decode()


def _tenant_fernet(tenant) -> Fernet:
    if not tenant.vault_key_wrapped:
        raise VaultError(
            "No vault has been created for this tenant yet. Create one from Admin → Vault before "
            "vaulting credentials or saving a Claude API key."
        )
    try:
        raw_key = _wrapping_fernet().decrypt(tenant.vault_key_wrapped.encode())
    except InvalidToken as exc:
        raise VaultError(
            "The stored vault key could not be unwrapped - SECRET_KEY may have changed since the "
            "vault was created."
        ) from exc
    return Fernet(raw_key)


def encrypt_secret(tenant, plaintext: str) -> str:
    return _tenant_fernet(tenant).encrypt(plaintext.encode()).decode()


def decrypt_secret(tenant, ciphertext: str) -> str:
    try:
        return _tenant_fernet(tenant).decrypt(ciphertext.encode()).decode()
    except InvalidToken as exc:
        raise VaultError("Stored value could not be decrypted.") from exc