mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 18:18:08 +00:00
Encrypt connection credentials with an owner-bound AES-GCM envelope
Adds a v2 credential envelope next to the v1 tool-secret helpers: AES-256-GCM, a master key derived once per process from ENCRYPTION_SECRET_KEY, and a per-record key from HKDF over the owner's id, which is also the associated data, so a blob moved onto another user's row does not decrypt. The envelope names its key, so ENCRYPTION_SECRET_KEY_PREVIOUS keeps old rows readable during a rotation. Log redaction now also covers token_info, tokens and client_info, and the API warns at startup when the public default key is in use.
This commit is contained in:
1 parent
45c4d928c4
commit
667d4bbab0
6 files changed
+275
-3
No files matched your search
@@ -184,6 +184,27 @@ if settings.AUTH_TYPE == "simple_jwt":
|
||||
print(f"Generated Simple JWT Token: {SIMPLE_JWT_TOKEN}")
|
||||
|
||||
|
||||
def _warn_default_encryption_key() -> None:
|
||||
"""Say when stored credentials are sealed with the public default key."""
|
||||
from docsgpt.security.encryption import is_default_encryption_key
|
||||
|
||||
if not is_default_encryption_key():
|
||||
return
|
||||
if settings.AUTH_TYPE:
|
||||
logging.getLogger(__name__).warning(
|
||||
"ENCRYPTION_SECRET_KEY is the public default: connecting services is refused until you set your "
|
||||
"own value (then run `docsgpt connectors reencrypt`)."
|
||||
)
|
||||
else:
|
||||
logging.getLogger(__name__).warning(
|
||||
"ENCRYPTION_SECRET_KEY is the public default. Stored connector credentials are only as safe as "
|
||||
"that key; set your own value before exposing this install."
|
||||
)
|
||||
|
||||
|
||||
_warn_default_encryption_key()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
if request.remote_addr in ("0.0.0.0", "127.0.0.1", "localhost", "172.18.0.1"):
|
||||
|
||||
@@ -29,7 +29,17 @@ class AuthSettings(SettingsGroup):
|
||||
)
|
||||
ENCRYPTION_SECRET_KEY: str = Field(
|
||||
default="default-docsgpt-encryption-key",
|
||||
description="Key used to encrypt stored credentials such as tool and connector secrets.",
|
||||
description=(
|
||||
"Key used to encrypt stored credentials such as tool and connector secrets. Set your own value before "
|
||||
"connecting services on a multi-user install; the default is public."
|
||||
),
|
||||
)
|
||||
ENCRYPTION_SECRET_KEY_PREVIOUS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Previous ENCRYPTION_SECRET_KEY, tried when a stored credential was encrypted with it. Set it while "
|
||||
"rotating the key, run `docsgpt connectors reencrypt`, then remove it."
|
||||
),
|
||||
)
|
||||
INTERNAL_KEY: Optional[str] = Field(
|
||||
default=None, description="Internal API key for worker-to-backend authentication."
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import algorithms, Cipher, modes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -86,3 +92,134 @@ def _pad_data(data: bytes) -> bytes:
|
||||
def _unpad_data(data: bytes) -> bytes:
|
||||
padding_len = data[-1]
|
||||
return data[:-padding_len]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envelope v2: connection credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``v2:<key_id>:<base64(salt | nonce | ciphertext+tag)>``
|
||||
#
|
||||
# AES-256-GCM, so a tampered blob fails to decrypt instead of returning
|
||||
# garbage. The master key is derived once per process from
|
||||
# ENCRYPTION_SECRET_KEY (PBKDF2, cached); each record gets its own key from
|
||||
# HKDF(master, salt, owner id), which keeps the v1 owner binding without
|
||||
# paying 100k PBKDF2 iterations on every token read in the worker. The owner
|
||||
# id is also the GCM associated data, so a blob copied onto another user's
|
||||
# row does not decrypt. ``key_id`` names the master key, so a blob written
|
||||
# under ENCRYPTION_SECRET_KEY_PREVIOUS is still readable during a rotation.
|
||||
|
||||
_V2_PREFIX = "v2"
|
||||
_V2_MASTER_SALT = b"docsgpt-credentials-v2"
|
||||
_V2_ITERATIONS = 200_000
|
||||
_V2_SALT_BYTES = 16
|
||||
_V2_NONCE_BYTES = 12
|
||||
DEFAULT_ENCRYPTION_KEY = "default-docsgpt-encryption-key"
|
||||
|
||||
|
||||
class CredentialDecryptionError(Exception):
|
||||
"""A stored credential could not be decrypted (wrong key, tampering, bad format)."""
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _master_key(secret: str) -> bytes:
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_V2_MASTER_SALT,
|
||||
iterations=_V2_ITERATIONS,
|
||||
backend=default_backend(),
|
||||
)
|
||||
return kdf.derive(secret.encode())
|
||||
|
||||
|
||||
def _key_id(master: bytes) -> str:
|
||||
return hmac.new(master, b"docsgpt-key-id", hashlib.sha256).hexdigest()[:8]
|
||||
|
||||
|
||||
def _record_key(master: bytes, owner_id: str, salt: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
info=b"docsgpt-v2|" + owner_id.encode(),
|
||||
backend=default_backend(),
|
||||
).derive(master)
|
||||
|
||||
|
||||
def _candidate_keys() -> dict[str, bytes]:
|
||||
"""Master keys this process can decrypt with, by key id (current first)."""
|
||||
keys: dict[str, bytes] = {}
|
||||
for secret in (settings.ENCRYPTION_SECRET_KEY, settings.ENCRYPTION_SECRET_KEY_PREVIOUS):
|
||||
if secret:
|
||||
master = _master_key(secret)
|
||||
keys.setdefault(_key_id(master), master)
|
||||
return keys
|
||||
|
||||
|
||||
def current_key_id() -> str:
|
||||
"""Key id of ENCRYPTION_SECRET_KEY, as written into new v2 blobs."""
|
||||
return _key_id(_master_key(settings.ENCRYPTION_SECRET_KEY))
|
||||
|
||||
|
||||
def is_default_encryption_key() -> bool:
|
||||
"""Whether ENCRYPTION_SECRET_KEY is still the public default."""
|
||||
return settings.ENCRYPTION_SECRET_KEY == DEFAULT_ENCRYPTION_KEY
|
||||
|
||||
|
||||
def encrypt_json(data: dict, owner_id: str) -> str:
|
||||
"""Encrypt ``data`` for ``owner_id`` into a v2 envelope.
|
||||
|
||||
Args:
|
||||
data: JSON-serialisable credentials.
|
||||
owner_id: The user the credentials belong to; decryption needs it.
|
||||
|
||||
Returns:
|
||||
The ``v2:<key_id>:<payload>`` string.
|
||||
"""
|
||||
master = _master_key(settings.ENCRYPTION_SECRET_KEY)
|
||||
key_id = _key_id(master)
|
||||
salt = os.urandom(_V2_SALT_BYTES)
|
||||
nonce = os.urandom(_V2_NONCE_BYTES)
|
||||
key = _record_key(master, owner_id, salt)
|
||||
plaintext = json.dumps(data, separators=(",", ":")).encode()
|
||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext, owner_id.encode())
|
||||
payload = base64.b64encode(salt + nonce + ciphertext).decode()
|
||||
return f"{_V2_PREFIX}:{key_id}:{payload}"
|
||||
|
||||
|
||||
def envelope_key_id(blob: str) -> Optional[str]:
|
||||
"""The key id a v2 blob was written with, or None for anything else."""
|
||||
parts = (blob or "").split(":", 2)
|
||||
if len(parts) != 3 or parts[0] != _V2_PREFIX:
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
|
||||
def decrypt_json(blob: str, owner_id: str) -> dict:
|
||||
"""Decrypt a v2 envelope written for ``owner_id``.
|
||||
|
||||
Raises:
|
||||
CredentialDecryptionError: The blob is malformed, was written with a
|
||||
key this process does not have, belongs to another owner, or was
|
||||
tampered with.
|
||||
"""
|
||||
key_id = envelope_key_id(blob)
|
||||
if key_id is None:
|
||||
raise CredentialDecryptionError("Not a v2 credential envelope")
|
||||
master = _candidate_keys().get(key_id)
|
||||
if master is None:
|
||||
raise CredentialDecryptionError("Credential was encrypted with an unknown key")
|
||||
try:
|
||||
raw = base64.b64decode(blob.split(":", 2)[2].encode(), validate=True)
|
||||
salt = raw[:_V2_SALT_BYTES]
|
||||
nonce = raw[_V2_SALT_BYTES:_V2_SALT_BYTES + _V2_NONCE_BYTES]
|
||||
ciphertext = raw[_V2_SALT_BYTES + _V2_NONCE_BYTES:]
|
||||
key = _record_key(master, owner_id, salt)
|
||||
plaintext = AESGCM(key).decrypt(nonce, ciphertext, owner_id.encode())
|
||||
data = json.loads(plaintext.decode())
|
||||
except Exception as exc:
|
||||
raise CredentialDecryptionError("Credential could not be decrypted") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CredentialDecryptionError("Credential payload is not an object")
|
||||
return data
|
||||
@@ -33,13 +33,17 @@ _SECRET_SUBSTRINGS = (
|
||||
"credential",
|
||||
"authorization",
|
||||
"bearer",
|
||||
# Connection secrets: an OAuth token_info blob, MCP token and client
|
||||
# registration dicts (``client_secret`` is covered by ``secret``).
|
||||
"token_info",
|
||||
"client_info",
|
||||
)
|
||||
|
||||
|
||||
def is_secret_key(key: str) -> bool:
|
||||
"""True when ``key`` names a credential that must not be persisted/returned."""
|
||||
k = key.lower()
|
||||
if k == "token":
|
||||
if k in ("token", "tokens"):
|
||||
return True
|
||||
return any(s in k for s in _SECRET_SUBSTRINGS)
|
||||
|
||||
|
||||
Reference in new issue
Block a user