Catalyst / admin/Syanpse-Vanguard 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Syanpse-Vanguard

public
Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Syanpse-Vanguard / synapse-vanguard-v3 / sv / vault.py 2278 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
"""AES-256-GCM credential vault — Python port of Synapse Sonar's lib/crypto.ts.

Secrets (integration configs, AI API keys) are stored as an EncryptedBlob JSON
{iv, authTag, ciphertext} (all base64). The plaintext never leaves the server
once stored; the UI only ever sees mask_secret()."""
from __future__ import annotations

import base64
import json
from dataclasses import dataclass

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from sv.config import settings


@dataclass(frozen=True)
class EncryptedBlob:
    iv: str
    auth_tag: str
    ciphertext: str

    def to_json(self) -> str:
        return json.dumps({"iv": self.iv, "authTag": self.auth_tag,
                           "ciphertext": self.ciphertext})

    @classmethod
    def from_json(cls, raw: str) -> "EncryptedBlob":
        d = json.loads(raw)
        return cls(iv=d["iv"], auth_tag=d["authTag"], ciphertext=d["ciphertext"])


def _key() -> bytes:
    hex_key = settings.vault_key
    if len(hex_key) != 64:
        raise ValueError("SV_VAULT_KEY must be 64 hex characters (32 bytes).")
    return bytes.fromhex(hex_key)


def encrypt_json(payload: dict) -> EncryptedBlob:
    """AESGCM in the `cryptography` lib appends the 16-byte tag to the ciphertext;
    we split it back out to match Sonar's {iv, authTag, ciphertext} shape."""
    iv = _rand(12)  # 96-bit nonce recommended for GCM
    aes = AESGCM(_key())
    ct_and_tag = aes.encrypt(iv, json.dumps(payload).encode(), None)
    ciphertext, tag = ct_and_tag[:-16], ct_and_tag[-16:]
    return EncryptedBlob(
        iv=_b64(iv), auth_tag=_b64(tag), ciphertext=_b64(ciphertext)
    )


def decrypt_json(blob: EncryptedBlob) -> dict:
    aes = AESGCM(_key())
    ct_and_tag = _unb64(blob.ciphertext) + _unb64(blob.auth_tag)
    plaintext = aes.decrypt(_unb64(blob.iv), ct_and_tag, None)
    return json.loads(plaintext.decode())


def mask_secret(value: str) -> str:
    """Render a secret for the UI as ••••••••<last4>, matching Sonar."""
    if not value:
        return ""
    return "•" * 8 + value[-4:]


def _rand(n: int) -> bytes:
    import os
    return os.urandom(n)


def _b64(b: bytes) -> str:
    return base64.b64encode(b).decode()


def _unb64(s: str) -> bytes:
    return base64.b64decode(s)